cardbuddy 0.11.0 → 0.12.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 (313) hide show
  1. package/README.md +46 -5
  2. package/dist/cli.js +51 -1
  3. package/dist/config.js +51 -16
  4. package/dist/core.js +2 -2
  5. package/dist/init.js +425 -0
  6. package/dist/preview-editor/assets/index-CHuGGIR3.js +276 -0
  7. package/dist/preview-editor/assets/{index-B4VZgNSK.css → index-CQScMhyg.css} +1 -1
  8. package/dist/preview-editor/index.html +2 -2
  9. package/dist/standalone.js +8 -0
  10. package/dist/vendored/mcp/client.mjs +537 -0
  11. package/dist/vendored/mcp/tools.mjs +268 -0
  12. package/dist/vendored/preview/assets/default-image.png +0 -0
  13. package/dist/{server-core → vendored/server/core}/ai-chat.js +82 -38
  14. package/dist/{server-core → vendored/server/core}/browser.js +1 -1
  15. package/dist/{server-core → vendored/server/core}/cardSave.js +8 -3
  16. package/dist/{server-core → vendored/server/core}/cardSync.js +6 -14
  17. package/dist/{server-core → vendored/server/core}/cardValidate.js +11 -8
  18. package/dist/{server-core → vendored/server/core}/index.js +5 -3
  19. package/dist/vendored/server/core/skillPaths.js +124 -0
  20. package/dist/{server-core → vendored/server/core}/sliceUpload.js +5 -8
  21. package/dist/vendored/skills/README.md +93 -0
  22. package/dist/vendored/skills/card-doctor/SKILL.md +52 -0
  23. package/dist/vendored/skills/card-skill-trainer/SKILL.md +312 -0
  24. package/dist/vendored/skills/card-skill-trainer/baselines/v3-r038n.json +43 -0
  25. package/dist/vendored/skills/card-skill-trainer/baselines/v3-r039n.json +47 -0
  26. package/dist/vendored/skills/card-skill-trainer/baselines/v3-r050n.json +48 -0
  27. package/dist/vendored/skills/card-skill-trainer/scripts/build_dataset.py +472 -0
  28. package/dist/vendored/skills/card-skill-trainer/scripts/build_sample.mjs +162 -0
  29. package/dist/vendored/skills/card-skill-trainer/scripts/diagnose_case.mjs +241 -0
  30. package/dist/vendored/skills/card-skill-trainer/scripts/lib/scoring.mjs +525 -0
  31. package/dist/vendored/skills/card-skill-trainer/scripts/round_diff.mjs +133 -0
  32. package/dist/vendored/skills/card-skill-trainer/scripts/run_round.mjs +1043 -0
  33. package/dist/vendored/skills/card-skill-trainer/scripts/score_round.mjs +195 -0
  34. package/dist/vendored/skills/card-skill-trainer/scripts/test_missing_slice.py +122 -0
  35. package/dist/vendored/skills/dsl-card-ai/SKILL.md +191 -0
  36. package/dist/vendored/skills/dsl-card-generator/SKILL.md +480 -0
  37. package/dist/vendored/skills/dsl-card-generator/config/render-contract.json +11 -0
  38. package/dist/vendored/skills/dsl-card-generator/references/amount_formatting.md +138 -0
  39. package/dist/vendored/skills/dsl-card-generator/references/card_index.json +1597 -0
  40. package/dist/vendored/skills/dsl-card-generator/references/card_templates.json +384 -0
  41. package/dist/vendored/skills/dsl-card-generator/references/card_to_summary.md +56 -0
  42. package/dist/vendored/skills/dsl-card-generator/references/component_rules.md +76 -0
  43. package/dist/vendored/skills/dsl-card-generator/references/component_specs.md +393 -0
  44. package/dist/vendored/skills/dsl-card-generator/references/dsl_structure.md +208 -0
  45. package/dist/vendored/skills/dsl-card-generator/references/event_patterns.md +287 -0
  46. package/dist/vendored/skills/dsl-card-generator/references/expression_engine.md +121 -0
  47. package/dist/vendored/skills/dsl-card-generator/references/field_name_dict.json +34 -0
  48. package/dist/vendored/skills/dsl-card-generator/references/field_naming.md +101 -0
  49. package/dist/vendored/skills/dsl-card-generator/references/generation_pitfalls.md +928 -0
  50. package/dist/vendored/skills/dsl-card-generator/references/handcrafted/README.md +69 -0
  51. package/dist/vendored/skills/dsl-card-generator/references/handcrafted/examples_index.json +2967 -0
  52. package/dist/vendored/skills/dsl-card-generator/references/handcrafted/patterns.json +2835 -0
  53. package/dist/vendored/skills/dsl-card-generator/references/handcrafted/recipes_v2.json +648 -0
  54. package/dist/vendored/skills/dsl-card-generator/references/handcrafted/recipes_v2_schema.md +295 -0
  55. package/dist/vendored/skills/dsl-card-generator/references/image_diff.md +48 -0
  56. package/dist/vendored/skills/dsl-card-generator/references/image_to_card.md +129 -0
  57. package/dist/vendored/skills/dsl-card-generator/references/ir-format.md +212 -0
  58. package/dist/vendored/skills/dsl-card-generator/references/message-center-pattern.md +149 -0
  59. package/dist/vendored/skills/dsl-card-generator/references/platform_jsapi.md +196 -0
  60. package/dist/vendored/skills/dsl-card-generator/references/previewer_contract.md +54 -0
  61. package/dist/vendored/skills/dsl-card-generator/references/quickref.md +197 -0
  62. package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/absolute-badge.json +84 -0
  63. package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/accordion-group-toggle.json +18 -0
  64. package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/alertCard-pair.json +12 -0
  65. package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/alertDialog-call.json +12 -0
  66. package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/canvas-countdown.json +15 -0
  67. package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/canvas-donut.json +15 -0
  68. package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/canvas-gauge-score.json +11 -0
  69. package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/canvas-radar.json +10 -0
  70. package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/canvas-title-head.json +13 -0
  71. package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/globalSet.json +12 -0
  72. package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/label-col-modes.json +21 -0
  73. package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/list-expand-collapse.json +17 -0
  74. package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/money-format.json +11 -0
  75. package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/opacity-switch.json +130 -0
  76. package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/progress-bar.json +25 -0
  77. package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/tab-billMode.json +15 -0
  78. package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/title-underline-overlay.json +21 -0
  79. package/dist/vendored/skills/dsl-card-generator/references/recipes.md +797 -0
  80. package/dist/vendored/skills/dsl-card-generator/references/rules.md +235 -0
  81. package/dist/vendored/skills/dsl-card-generator/references/slices_convention.md +177 -0
  82. package/dist/vendored/skills/dsl-card-generator/references/spec_to_schema_patterns.md +609 -0
  83. package/dist/vendored/skills/dsl-card-generator/references/ui_fidelity_rules.md +131 -0
  84. package/dist/vendored/skills/dsl-card-generator/scripts/add_component.mjs +118 -0
  85. package/dist/vendored/skills/dsl-card-generator/scripts/analyze_component_usage.mjs +153 -0
  86. package/dist/vendored/skills/dsl-card-generator/scripts/apply_opacity_pattern.mjs +343 -0
  87. package/dist/vendored/skills/dsl-card-generator/scripts/build_card_index.mjs +108 -0
  88. package/dist/vendored/skills/dsl-card-generator/scripts/build_card_templates.mjs +190 -0
  89. package/dist/vendored/skills/dsl-card-generator/scripts/check_fidelity.mjs +257 -0
  90. package/dist/vendored/skills/dsl-card-generator/scripts/check_magic_values.mjs +79 -0
  91. package/dist/vendored/skills/dsl-card-generator/scripts/crop_slices.mjs +98 -0
  92. package/dist/vendored/skills/dsl-card-generator/scripts/describe_card.mjs +265 -0
  93. package/dist/vendored/skills/dsl-card-generator/scripts/diff_bands.mjs +257 -0
  94. package/dist/vendored/skills/dsl-card-generator/scripts/diff_card.mjs +316 -0
  95. package/dist/vendored/skills/dsl-card-generator/scripts/expand_ir.mjs +873 -0
  96. package/dist/vendored/skills/dsl-card-generator/scripts/extract_handcrafted.py +1199 -0
  97. package/dist/vendored/skills/dsl-card-generator/scripts/extract_subtree.mjs +191 -0
  98. package/dist/vendored/skills/dsl-card-generator/scripts/generate_cards.mjs +389 -0
  99. package/dist/vendored/skills/dsl-card-generator/scripts/inventory_schemas.py +160 -0
  100. package/dist/vendored/skills/dsl-card-generator/scripts/iterate.mjs +700 -0
  101. package/dist/vendored/skills/dsl-card-generator/scripts/lib/design_image.mjs +56 -0
  102. package/dist/vendored/skills/dsl-card-generator/scripts/lib/design_sample.mjs +328 -0
  103. package/dist/vendored/skills/dsl-card-generator/scripts/lib/layout-tokens.mjs +79 -0
  104. package/dist/vendored/skills/dsl-card-generator/scripts/lib/paths.mjs +103 -0
  105. package/dist/vendored/skills/dsl-card-generator/scripts/lib/scaffold.mjs +575 -0
  106. package/dist/vendored/skills/dsl-card-generator/scripts/lib/slicePath.mjs +81 -0
  107. package/dist/vendored/skills/dsl-card-generator/scripts/lib/slice_dedupe.mjs +147 -0
  108. package/dist/vendored/skills/dsl-card-generator/scripts/lint_autofix.mjs +363 -0
  109. package/dist/vendored/skills/dsl-card-generator/scripts/lint_card.mjs +1399 -0
  110. package/dist/vendored/skills/dsl-card-generator/scripts/preflight.mjs +249 -0
  111. package/dist/vendored/skills/dsl-card-generator/scripts/register_card_template.mjs +208 -0
  112. package/dist/vendored/skills/dsl-card-generator/scripts/render_baseline.mjs +231 -0
  113. package/dist/vendored/skills/dsl-card-generator/scripts/render_card.mjs +801 -0
  114. package/dist/vendored/skills/dsl-card-generator/scripts/render_diff.mjs +239 -0
  115. package/dist/vendored/skills/dsl-card-generator/scripts/resolve_card_template.mjs +73 -0
  116. package/dist/vendored/skills/dsl-card-generator/scripts/route_input.mjs +447 -0
  117. package/dist/vendored/skills/dsl-card-generator/scripts/scaffold_card.mjs +81 -0
  118. package/dist/vendored/skills/dsl-card-generator/scripts/slices_normalize.mjs +316 -0
  119. package/dist/vendored/skills/dsl-card-generator/scripts/spec_audit.mjs +462 -0
  120. package/dist/vendored/skills/dsl-card-generator/scripts/spec_to_schema_v3.mjs +7735 -0
  121. package/dist/vendored/skills/dsl-card-generator/scripts/spec_tree.mjs +355 -0
  122. package/dist/vendored/skills/dsl-card-generator/scripts/validate_all_schemas.py +71 -0
  123. package/dist/vendored/skills/dsl-card-generator/vendor/preview/assets/default-image-wzZYQ86K.png +0 -0
  124. package/dist/vendored/skills/dsl-card-generator/vendor/preview/assets/standalone-C9k-07y7.css +1 -0
  125. package/dist/vendored/skills/dsl-card-generator/vendor/preview/assets/standalone-oFRM4f4V.js +55 -0
  126. package/dist/vendored/skills/dsl-card-generator/vendor/preview/assets/svg-symbols-B2exBrAr.svg +118 -0
  127. package/dist/vendored/skills/dsl-card-generator/vendor/preview/meta/component-meta.json +2714 -0
  128. package/dist/vendored/skills/dsl-card-generator/vendor/preview/meta/directive-meta.json +40 -0
  129. package/dist/vendored/skills/dsl-card-generator/vendor/preview/meta/style-meta.json +1086 -0
  130. package/dist/vendored/{standalone.html → skills/dsl-card-generator/vendor/preview/standalone.html} +2 -1
  131. package/dist/vendored/skills/finmall-card-sync/SKILL.md +262 -0
  132. package/dist/vendored/skills/finmall-session-navigator/SKILL.md +169 -0
  133. package/dist/vendored/skills/finmall-session-navigator/scripts/finmall_login.mjs +882 -0
  134. package/dist/vendored/skills/finmall-session-navigator/scripts/package-lock.json +59 -0
  135. package/dist/vendored/skills/finmall-session-navigator/scripts/package.json +10 -0
  136. package/dist/vendored/skills/finmall-session-navigator/scripts/session-manager.mjs +96 -0
  137. package/dist/vendored/skills/shared/README.md +55 -0
  138. package/dist/vendored/skills/shared/auth-model.md +79 -0
  139. package/dist/vendored/skills/sketch-spec-to-card/SKILL.md +329 -0
  140. package/dist/vendored/skills/sketch-spec-to-card/references/boundary.json +14 -0
  141. package/dist/vendored/skills/sketch-spec-to-card/references/card_refs.json +13 -0
  142. package/dist/vendored/skills/sketch-spec-to-card/references/spec-schema.md +103 -0
  143. package/dist/vendored/skills/sketch-spec-to-card/scripts/build_prompt.py +797 -0
  144. package/dist/vendored/skills/sketch-spec-to-card/scripts/card_refs.py +94 -0
  145. package/dist/vendored/skills/sketch-spec-to-card/scripts/cluster_states.py +706 -0
  146. package/dist/vendored/skills/sketch-spec-to-card/scripts/parse_meaxure.py +1781 -0
  147. package/dist/vendored/skills/sketch-spec-to-card/scripts/png_utils.py +318 -0
  148. package/dist/vendored/skills/sketch-spec-to-card/scripts/vision.py +1118 -0
  149. package/dist/vendored/templates/helloCard/data.json +10 -0
  150. package/dist/vendored/templates/helloCard/schema.json +238 -0
  151. package/dist/vendored/tools/link-skills.mjs +485 -0
  152. package/package.json +2 -1
  153. package/dist/preview-editor/assets/index-jl4YJzKv.js +0 -180
  154. package/dist/vendored/App.vue +0 -2858
  155. package/dist/vendored/ansi.js +0 -185
  156. package/dist/vendored/assets/apple-touch-icon.png +0 -0
  157. package/dist/vendored/assets/default-image.png +0 -0
  158. package/dist/vendored/assets/favicon-16.png +0 -0
  159. package/dist/vendored/assets/favicon-32.png +0 -0
  160. package/dist/vendored/assets/site-icon.png +0 -0
  161. package/dist/vendored/assets/svgIcons/icons.svg +0 -87
  162. package/dist/vendored/assets/svgIcons/index.js +0 -39
  163. package/dist/vendored/components/AiSettings.vue +0 -392
  164. package/dist/vendored/components/AlertCardInstance.vue +0 -58
  165. package/dist/vendored/components/AlertCardLayer.vue +0 -59
  166. package/dist/vendored/components/ApiCallPanel.vue +0 -127
  167. package/dist/vendored/components/ApiPayloadTree.vue +0 -201
  168. package/dist/vendored/components/AppToast.vue +0 -37
  169. package/dist/vendored/components/CardGallery.vue +0 -291
  170. package/dist/vendored/components/CardPreview.vue +0 -72
  171. package/dist/vendored/components/CardSelect.vue +0 -206
  172. package/dist/vendored/components/ChatComposer.vue +0 -413
  173. package/dist/vendored/components/ChatMessage.vue +0 -383
  174. package/dist/vendored/components/ChatPanel.vue +0 -937
  175. package/dist/vendored/components/ComponentPalette.vue +0 -301
  176. package/dist/vendored/components/ConfirmDialog.vue +0 -54
  177. package/dist/vendored/components/DatePickerOverlay.vue +0 -345
  178. package/dist/vendored/components/DebugPanel.vue +0 -1734
  179. package/dist/vendored/components/DeviceOverlay.vue +0 -70
  180. package/dist/vendored/components/Dropdown.vue +0 -193
  181. package/dist/vendored/components/EventEditor.vue +0 -273
  182. package/dist/vendored/components/HoverGuides.vue +0 -122
  183. package/dist/vendored/components/JsonTree.vue +0 -547
  184. package/dist/vendored/components/LogValue.vue +0 -52
  185. package/dist/vendored/components/MarkdownBody.vue +0 -64
  186. package/dist/vendored/components/NewCardDialog.vue +0 -132
  187. package/dist/vendored/components/NodeInspector.vue +0 -295
  188. package/dist/vendored/components/NumberStepper.vue +0 -125
  189. package/dist/vendored/components/PickerOverlay.vue +0 -131
  190. package/dist/vendored/components/PropControl.vue +0 -245
  191. package/dist/vendored/components/PropTree.vue +0 -128
  192. package/dist/vendored/components/RemotePreview.vue +0 -173
  193. package/dist/vendored/components/SelectionOverlay.vue +0 -105
  194. package/dist/vendored/components/StructureTree.vue +0 -1002
  195. package/dist/vendored/components/Switch.vue +0 -38
  196. package/dist/vendored/components/setters/SetBgPosition.vue +0 -64
  197. package/dist/vendored/components/setters/SetBgSize.vue +0 -90
  198. package/dist/vendored/components/setters/SetCode.vue +0 -198
  199. package/dist/vendored/components/setters/SetColor.vue +0 -244
  200. package/dist/vendored/components/setters/SetCustomSelect.vue +0 -58
  201. package/dist/vendored/components/setters/SetExpr.vue +0 -107
  202. package/dist/vendored/components/setters/SetGradient.vue +0 -125
  203. package/dist/vendored/components/setters/SetGrid9.vue +0 -68
  204. package/dist/vendored/components/setters/SetIconSelect.vue +0 -67
  205. package/dist/vendored/components/setters/SetIfForm.vue +0 -61
  206. package/dist/vendored/components/setters/SetImage.vue +0 -43
  207. package/dist/vendored/components/setters/SetList.vue +0 -139
  208. package/dist/vendored/components/setters/SetNumber.vue +0 -118
  209. package/dist/vendored/components/setters/SetNumberStyle.vue +0 -113
  210. package/dist/vendored/components/setters/SetPadding.vue +0 -77
  211. package/dist/vendored/components/setters/SetSegment.vue +0 -160
  212. package/dist/vendored/components/setters/SetSelect.vue +0 -51
  213. package/dist/vendored/components/setters/SetSwitch.vue +0 -49
  214. package/dist/vendored/components/setters/SetText.vue +0 -52
  215. package/dist/vendored/components/setters/props.js +0 -23
  216. package/dist/vendored/fidelity/baseline-messageOverview.json +0 -87
  217. package/dist/vendored/fidelity/calibration.json +0 -29
  218. package/dist/vendored/fidelity/fidelity-messageOverview-after.html +0 -114
  219. package/dist/vendored/fidelity/fidelity-messageOverview-after.json +0 -272
  220. package/dist/vendored/fidelity/fidelity-messageOverview.html +0 -185
  221. package/dist/vendored/fidelity/fidelity-messageOverview.json +0 -336
  222. package/dist/vendored/fidelity/fidelity-messageOverview_v2.html +0 -262
  223. package/dist/vendored/fidelity/fidelity-messageOverview_v2.json +0 -502
  224. package/dist/vendored/fidelity/fidelity-messageOverview_v3.html +0 -201
  225. package/dist/vendored/fidelity/fidelity-messageOverview_v3.json +0 -417
  226. package/dist/vendored/fidelity/fidelity-messageOverview_visual.html +0 -125
  227. package/dist/vendored/fidelity/fidelity-messageOverview_visual.json +0 -338
  228. package/dist/vendored/index.html +0 -16
  229. package/dist/vendored/main.js +0 -37
  230. package/dist/vendored/runtime/ai-api.js +0 -165
  231. package/dist/vendored/runtime/alert-card.js +0 -125
  232. package/dist/vendored/runtime/app-toast.js +0 -33
  233. package/dist/vendored/runtime/clipboard-node.js +0 -78
  234. package/dist/vendored/runtime/clipboard.js +0 -29
  235. package/dist/vendored/runtime/event-model.js +0 -300
  236. package/dist/vendored/runtime/hoverGuide.js +0 -31
  237. package/dist/vendored/runtime/jsapi-mock.js +0 -256
  238. package/dist/vendored/runtime/markdown.js +0 -140
  239. package/dist/vendored/runtime/node-model.js +0 -217
  240. package/dist/vendored/skills/.manifest.json +0 -1101
  241. package/dist/vendored/standalone-main.js +0 -36
  242. package/dist/vendored/styles/ai-chat.css +0 -1850
  243. package/dist/vendored/styles/app.css +0 -3648
  244. package/dist/vendored/styles/card-gallery.css +0 -285
  245. package/dist/vendored/styles/card-select.css +0 -288
  246. package/dist/vendored/styles/components.css +0 -383
  247. package/dist/vendored/styles/dropdown.css +0 -324
  248. package/dist/vendored/styles/json-tree.css +0 -308
  249. package/dist/vendored/styles/node-inspector.css +0 -800
  250. package/dist/vendored/styles/setters.css +0 -1050
  251. package/dist/vendored/styles/standalone.css +0 -27
  252. package/dist/vendored/styles/structure-tree.css +0 -460
  253. /package/dist/vendored/{api.js → preview/api.js} +0 -0
  254. /package/dist/vendored/{assets → preview/assets}/svg-symbols.svg +0 -0
  255. /package/dist/vendored/{components → preview/components}/AbsoluteLayout.vue +0 -0
  256. /package/dist/vendored/{components → preview/components}/Button.vue +0 -0
  257. /package/dist/vendored/{components → preview/components}/Canvas.vue +0 -0
  258. /package/dist/vendored/{components → preview/components}/CheckGroup.vue +0 -0
  259. /package/dist/vendored/{components → preview/components}/Column.vue +0 -0
  260. /package/dist/vendored/{components → preview/components}/CustomPlaceholder.vue +0 -0
  261. /package/dist/vendored/{components → preview/components}/Divider.vue +0 -0
  262. /package/dist/vendored/{components → preview/components}/GridLayout.vue +0 -0
  263. /package/dist/vendored/{components → preview/components}/Icon.vue +0 -0
  264. /package/dist/vendored/{components → preview/components}/Image.vue +0 -0
  265. /package/dist/vendored/{components → preview/components}/Input.vue +0 -0
  266. /package/dist/vendored/{components → preview/components}/ListLayout.vue +0 -0
  267. /package/dist/vendored/{components → preview/components}/Map.vue +0 -0
  268. /package/dist/vendored/{components → preview/components}/MultiColumn.vue +0 -0
  269. /package/dist/vendored/{components → preview/components}/MultiText.vue +0 -0
  270. /package/dist/vendored/{components → preview/components}/ProgressSlider.vue +0 -0
  271. /package/dist/vendored/{components → preview/components}/RadioButton.vue +0 -0
  272. /package/dist/vendored/{components → preview/components}/RenderTree.vue +0 -0
  273. /package/dist/vendored/{components → preview/components}/Root.vue +0 -0
  274. /package/dist/vendored/{components → preview/components}/Row.vue +0 -0
  275. /package/dist/vendored/{components → preview/components}/SvgCode.vue +0 -0
  276. /package/dist/vendored/{components → preview/components}/SvgSymbols.vue +0 -0
  277. /package/dist/vendored/{components → preview/components}/Text.vue +0 -0
  278. /package/dist/vendored/{components → preview/components}/Video.vue +0 -0
  279. /package/dist/vendored/{components → preview/components}/ViewContainer.vue +0 -0
  280. /package/dist/vendored/{components → preview/components}/base.js +0 -0
  281. /package/dist/vendored/{components → preview/components}/icons.js +0 -0
  282. /package/dist/vendored/{components → preview/components}/index.js +0 -0
  283. /package/dist/vendored/{meta → preview/meta}/component-meta.json +0 -0
  284. /package/dist/vendored/{meta → preview/meta}/directive-meta.json +0 -0
  285. /package/dist/vendored/{meta → preview/meta}/style-meta.json +0 -0
  286. /package/dist/vendored/{runtime → preview/runtime}/StyleParser.js +0 -0
  287. /package/dist/vendored/{runtime → preview/runtime}/adapter.js +0 -0
  288. /package/dist/vendored/{runtime → preview/runtime}/assets.js +0 -0
  289. /package/dist/vendored/{runtime → preview/runtime}/canvas-context.js +0 -0
  290. /package/dist/vendored/{runtime → preview/runtime}/card-runtime.js +0 -0
  291. /package/dist/vendored/{runtime → preview/runtime}/evaluate.js +0 -0
  292. /package/dist/vendored/{runtime → preview/runtime}/property.js +0 -0
  293. /package/dist/vendored/{runtime → preview/runtime}/selectionOverlay.js +0 -0
  294. /package/dist/vendored/{runtime → preview/runtime}/toPreview.js +0 -0
  295. /package/dist/{server-core → vendored/server/core}/assetProxy.js +0 -0
  296. /package/dist/{server-core → vendored/server/core}/bbox.js +0 -0
  297. /package/dist/{server-core → vendored/server/core}/buildDataFromSchema.js +0 -0
  298. /package/dist/{server-core → vendored/server/core}/cardFiles.js +0 -0
  299. /package/dist/{server-core → vendored/server/core}/cardHistory.js +0 -0
  300. /package/dist/{server-core → vendored/server/core}/cardImage.js +0 -0
  301. /package/dist/{server-core → vendored/server/core}/cardImportMeta.js +0 -0
  302. /package/dist/{server-core → vendored/server/core}/cardListCache.js +0 -0
  303. /package/dist/{server-core → vendored/server/core}/cardOpenFolder.js +0 -0
  304. /package/dist/{server-core → vendored/server/core}/cardSidecar.js +0 -0
  305. /package/dist/{server-core → vendored/server/core}/codeBridge.js +0 -0
  306. /package/dist/{server-core → vendored/server/core}/logStream.js +0 -0
  307. /package/dist/{server-core → vendored/server/core}/logger.js +0 -0
  308. /package/dist/{server-core → vendored/server/core}/nodeModels.js +0 -0
  309. /package/dist/{server-core → vendored/server/core}/renderStatus.js +0 -0
  310. /package/dist/{server-core → vendored/server/core}/screenshot.js +0 -0
  311. /package/dist/{server-core → vendored/server/core}/sliceStatic.js +0 -0
  312. /package/dist/vendored/{meta → skills/dsl-card-generator/vendor/preview/meta}/action-meta.json +0 -0
  313. /package/dist/{server-core/_vendor → vendored/tools}/finmall-env.mjs +0 -0
@@ -0,0 +1,1781 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ parse_meaxure.py — 解析 Sketch MeaXure 标注导出,视觉驱动生成「卡片要素包」。
5
+
6
+ 原则(2026-08-14 重构):
7
+ - 判定依据全部来自视觉几何(大块、独立、卡片状),文本/名称仅是附属信息,
8
+ 不参与「保留/剔除」判定 —— 设计稿文本多为示意,未必有真实含义。
9
+ - 一个画板可有 1..N 张卡片(空间分离的视觉块各自成卡),聚焦后裁切输出局部图。
10
+ - 产物按「卡片维度」组织:一张卡片可对应多个画板(多个状态),每个状态一张/多张聚焦截图;
11
+ 子卡/弹框作为独立卡片,仅通过交互关系(parentKey / JSAPI)与父卡建立联系。
12
+
13
+ 产物结构(--out,默认 <仓库根>/UI):
14
+ manifest.json 画板清单 + 卡片映射
15
+ cards/<cardKey>/
16
+ card.json 状态 / 交互 / 样式 / 列表 聚合信息
17
+ states/<画板名>/
18
+ artboard.png 整图(参考)
19
+ card-1.png(...) 聚焦裁切图(每张卡片或每板一图,@2x)
20
+ spec.json 视觉要素标注(该状态)
21
+ slices/ 切图素材
22
+ clusters.json 卡片聚类 + 交互维度(由 cluster_states.py 补全)
23
+
24
+ 用法:
25
+ python parse_meaxure.py --list # 仅列画板
26
+ # 多画板选择门禁:不带 --only/--indices/--sample/--scope-all 的生成运行会打印 1..n+全部 清单并 exit 4,
27
+ # 等用户回复序号/「全部」后再带 --indices <k-1> / --only / --scope-all 重跑(rules.md §十三)。
28
+ python parse_meaxure.py --sample 5 # 抽样代表性画板
29
+ python parse_meaxure.py --only "未登录,有交易" # 按名称子串
30
+ python parse_meaxure.py --indices 0,9,18 # 按序号
31
+ python parse_meaxure.py --src <导出目录> --out <UI> --build
32
+ python parse_meaxure.py --build # 一步式(含 build_prompt)
33
+ """
34
+ import argparse
35
+ import json
36
+ import math
37
+ import os
38
+ import re
39
+ import shutil
40
+ import sys
41
+ from collections import defaultdict
42
+ from datetime import datetime
43
+ from pathlib import Path
44
+ from urllib.parse import unquote
45
+
46
+ SCRIPT_DIR = Path(__file__).resolve().parent
47
+ SKILL_DIR = SCRIPT_DIR.parent
48
+ REPO_ROOT = SKILL_DIR.parent.parent
49
+ DEFAULT_OUT = REPO_ROOT / "UI"
50
+ DEFAULT_SRC = Path(os.environ.get('MEAXURE_SRC') or str(Path.cwd())) # 可用环境变量 MEAXURE_SRC 指定,缺省当前目录
51
+
52
+ sys.path.insert(0, str(SCRIPT_DIR))
53
+ import png_utils
54
+ import vision
55
+
56
+ PREVIEW_SCALE = 2.0 # 预览截图 750x1624 = 设计稿 375x812 的 @2x
57
+
58
+
59
+ # ---------- 通用工具 ----------
60
+ def normalize_name(name: str) -> str:
61
+ """剥画板名开头的数字序号前缀(01转账 → 转账)。"""
62
+ return re.sub(r"^\d+", "", name or "").strip()
63
+
64
+
65
+ def flow_prefix(name: str) -> str:
66
+ """业务流前缀:画板名第一个 '_' 前段(如 01转账 / 41转账记录)。"""
67
+ return normalize_name(name).split("_", 1)[0]
68
+
69
+
70
+ def hex_of(color_obj):
71
+ """MeaXure color -> '#RRGGBB'。"""
72
+ if not isinstance(color_obj, dict):
73
+ return None
74
+ for key in ("color-hex", "argb-hex", "rgba-hex"):
75
+ v = color_obj.get(key)
76
+ if isinstance(v, str) and v.startswith("#"):
77
+ return v.split()[0][:7] if " " not in v else v[:7]
78
+ return None
79
+
80
+
81
+ def rgba_of(color_obj):
82
+ """MeaXure color -> 'rgba(r,g,b,a)'(含透明度)。"""
83
+ if not isinstance(color_obj, dict):
84
+ return None
85
+ css = color_obj.get("css-rgba") or color_obj.get("css-hsla")
86
+ if css:
87
+ return css
88
+ h = hex_of(color_obj)
89
+ return h
90
+
91
+
92
+ def extract_data(index_html: Path) -> dict:
93
+ html = index_html.read_text(encoding="utf-8", errors="ignore")
94
+ marker = "let data = "
95
+ i = html.find(marker)
96
+ if i < 0:
97
+ raise RuntimeError("未找到 'let data = ',可能不是 Sketch MeaXure 导出")
98
+ start = html.find("{", i)
99
+ depth = 0
100
+ j = start
101
+ while j < len(html):
102
+ c = html[j]
103
+ if c == "{":
104
+ depth += 1
105
+ elif c == "}":
106
+ depth -= 1
107
+ if depth == 0:
108
+ break
109
+ j += 1
110
+ return json.loads(html[start:j + 1])
111
+
112
+
113
+ # ---------- 视觉要素提取(对齐卡片平台节点属性) ----------
114
+ _STYLE_NONE = {"color": None, "gradient": None, "image": None}
115
+
116
+ def extract_fills_style(fills):
117
+ """fills -> {color, gradient, image}(首有效填充,平台 background 组参考)。"""
118
+ out = dict(_STYLE_NONE)
119
+ if not fills:
120
+ return out
121
+ for f in fills:
122
+ if not isinstance(f, dict):
123
+ continue
124
+ ft = f.get("fillType") or f.get("type") or ""
125
+ if "grad" in str(ft).lower():
126
+ g = f.get("gradient") or {}
127
+ stops = g.get("colorStops") or []
128
+ css_stops = []
129
+ for s in stops:
130
+ c = s.get("color") or {}
131
+ css = c.get("css-rgba") or c.get("css-hsla") or ""
132
+ pos = s.get("position")
133
+ if css and pos is not None:
134
+ css_stops.append(f"{css} {round(pos * 100)}%")
135
+ if css_stops:
136
+ gtype = str(g.get("type", "Linear")).lower()
137
+ if gtype.startswith("radial"):
138
+ out["gradient"] = "radial-gradient(" + ", ".join(css_stops) + ")"
139
+ else:
140
+ frm, to = g.get("from", {}), g.get("to", {})
141
+ fx, fy = frm.get("x", 0.5), frm.get("y", 0)
142
+ tx, ty = to.get("x", 0.5), to.get("y", 1)
143
+ # 方向向量必须保留符号:from→to 指向哪边,渐变就朝哪边。
144
+ # 只看坐标差绝对值会把「自下而上」误判成 to bottom(整体反向)。
145
+ # CSS 角度约定:0deg=to top,90deg=to right,180deg=to bottom。
146
+ dx, dy = (tx - fx), (ty - fy)
147
+ if abs(dx) < 1e-6 and abs(dy) < 1e-6:
148
+ angle = "to bottom"
149
+ else:
150
+ deg = round((math.degrees(math.atan2(dx, -dy)) + 360) % 360)
151
+ snap = {0: "to top", 90: "to right", 180: "to bottom", 270: "to left"}
152
+ angle = snap.get(deg, f"{deg}deg")
153
+ out["gradient"] = f"linear-gradient({angle}, " + ", ".join(css_stops) + ")"
154
+ if out["gradient"]:
155
+ break
156
+ elif f.get("color"):
157
+ h = hex_of(f["color"])
158
+ if h:
159
+ out["color"] = h
160
+ break
161
+ return out
162
+
163
+
164
+ def extract_border_style(borders):
165
+ """borders -> {isBorder, color, width}。"""
166
+ if not borders:
167
+ return {"isBorder": False, "color": None, "width": None}
168
+ b = borders[0]
169
+ return {
170
+ "isBorder": True,
171
+ "color": hex_of(b.get("color")) if isinstance(b, dict) else None,
172
+ "width": b.get("thickness") if isinstance(b, dict) else None,
173
+ }
174
+
175
+
176
+ def extract_radius(radius):
177
+ """radius 列表 -> 平台 borderRadius 参考(单值或四角)。"""
178
+ if not radius:
179
+ return None
180
+ vals = [round(float(v), 1) if isinstance(v, (int, float)) and v else 0 for v in radius]
181
+ vals = vals[:4]
182
+ if len(vals) == 4 and len(set(vals)) > 1:
183
+ return {"borderRadiusType": "1", "borderRadius": vals} # 四角
184
+ if vals and vals[0] > 0:
185
+ return {"borderRadiusType": "0", "borderRadius": vals[0]}
186
+ return {"borderRadiusType": "0", "borderRadius": 0}
187
+
188
+
189
+ def extract_shadow(shadows):
190
+ """shadows -> {isShadow, color, x, y, blur}。"""
191
+ if not shadows:
192
+ return {"isShadow": False}
193
+ s = shadows[0]
194
+ c = (s.get("color") or {})
195
+ return {
196
+ "isShadow": True,
197
+ "color": hex_of(c),
198
+ "x": s.get("x") or s.get("offsetX"),
199
+ "y": s.get("y") or s.get("offsetY"),
200
+ "blur": s.get("blur") or s.get("radius"),
201
+ }
202
+
203
+
204
+ def extract_text_style(raw):
205
+ """文本层 -> font 组参考(平台节点 Text 属性)。"""
206
+ css = raw.get("css") or []
207
+ css_text = " ".join(css) if isinstance(css, list) else str(css or "")
208
+ def css_val(prop):
209
+ m = re.search(prop + r"\s*:\s*([^;]+)", css_text)
210
+ return m.group(1).strip() if m else None
211
+ weight = css_val(r"font-weight")
212
+ style = css_val(r"font-style")
213
+ family = css_val(r"font-family")
214
+ return {
215
+ "color": hex_of(raw.get("color")),
216
+ "fontSize": raw.get("fontSize"),
217
+ "fontFace": raw.get("fontFace"),
218
+ "fontFamily": family or raw.get("fontFace"),
219
+ "fontWeight": weight or "400",
220
+ "fontStyle": style or "normal",
221
+ "lineHeight": raw.get("lineHeight"),
222
+ "letterSpacing": raw.get("letterSpacing"),
223
+ "textAlign": raw.get("textAlign"),
224
+ "rotation": raw.get("rotation", 0),
225
+ }
226
+
227
+
228
+ def element_style(raw):
229
+ """一个图层的平台对齐样式参考(非文本)。"""
230
+ return {
231
+ "fill": extract_fills_style(raw.get("fills")),
232
+ "background": extract_fills_style(raw.get("fills")),
233
+ "border": extract_border_style(raw.get("borders")),
234
+ "radius": extract_radius(raw.get("radius")),
235
+ "shadow": extract_shadow(raw.get("shadows")),
236
+ "opacity": raw.get("opacity", 1),
237
+ "rotation": raw.get("rotation", 0),
238
+ "styleName": raw.get("styleName"),
239
+ }
240
+
241
+
242
+ def serialize_element(ve, raw):
243
+ """把视觉元素序列化为 spec.json 条目(几何 + 视觉 + 附属文本)。
244
+
245
+ 注意:MeaXure 导出的 layers 是设计师纯视觉图层树,不等于卡片节点拓扑,
246
+ 故不产出层级字段;卡片拓扑由下游几何/坐标重建(v3 extractSemantics)权威生成。
247
+ """
248
+ x, y, w, h = ve.rect
249
+ return {
250
+ "name": ve.name,
251
+ "type": ve.type,
252
+ "rect": {"x": round(x, 2), "y": round(y, 2), "width": round(w, 2), "height": round(h, 2)},
253
+ "style": element_style(raw),
254
+ "text": raw.get("content"), # 附属信息(仅记录,不参与判定)
255
+ "textStyle": extract_text_style(raw) if ve.is_text else None,
256
+ "slices": raw.get("name") if ve.is_slice else None,
257
+ }
258
+ # ---------- 卡片视觉分段(几何驱动,供下游拓扑生成;group 结构不作依据) ----------
259
+ # 设计稿是扁平绝对定位画布,group 只是编辑组织;卡片是流式 flex 树。
260
+ # 分段只认 y 几何(大间隙/类型切换),产出每段的卡内 y/height/hint,下游据此生成容器拓扑。
261
+ SEG_GAP_RATIO = 0.02 # 段间间隙阈值(相对卡高,超此视为分段边界)
262
+ SEG_BG_W_RATIO = 0.8 # 背景层宽占比(≥卡宽 80%)
263
+ SEG_BG_H_MAX = 220 # 背景层最大高
264
+
265
+
266
+ def _segment_hint(e, card_w, card_rect=None):
267
+ """段类型 hint(仅参考,下游可覆盖):decor-bg / button / footer / content"""
268
+ t = e.get("type")
269
+ r = e.get("rect") or {}
270
+ w = r.get("width") or 0
271
+ h = r.get("height") or 0
272
+ bounds = card_rect or {}
273
+ card_y = float(bounds[1] if isinstance(bounds, tuple) else bounds.get("y", 0) or 0)
274
+ card_h = float(bounds[3] if isinstance(bounds, tuple) else bounds.get("height", 0) or 0)
275
+ near_top = card_h <= 0 or (float(r.get("y", 0) or 0) - card_y) <= max(40, card_h * 0.2)
276
+ is_background_media = t in ("slice", "group") and w >= card_w * SEG_BG_W_RATIO and h <= SEG_BG_H_MAX
277
+ if is_background_media and near_top:
278
+ return "decor-bg"
279
+ has_gradient = t == "shape" and ((e.get("style") or {}).get("fill") or {}).get("gradient")
280
+ if has_gradient and h <= 80:
281
+ return "button"
282
+ if has_gradient and w >= card_w * 0.9 and h >= card_h * 0.9:
283
+ return "decor-bg"
284
+ if t == "text":
285
+ txt = str(e.get("text") or "").strip()
286
+ # 通用 UI 动作词(中英双语)——这些是跨业务通用的交互动作词,非业务词
287
+ if re.search(r"^(更多|查看|展开|收起|提交|确认|立即|完成|确定|取消|关闭|返回|more|view|expand|collapse|submit|confirm|cancel|close|done)", txt, re.I):
288
+ return "button"
289
+ # 几何兜底:按钮尺寸范围内的短文本(≤8 字、高度 20-56px、宽度 ≤240)
290
+ r = e.get("rect") or {}
291
+ h = float(r.get("height") or 0)
292
+ w = float(r.get("width") or 0)
293
+ if 20 <= h <= 56 and w <= 240 and 0 < len(txt) <= 8:
294
+ return "button"
295
+ return "content"
296
+
297
+
298
+ def detect_segments(card_els, card_rect):
299
+ """按 y 几何聚类视觉段。返回 [{y, height, gap_top, hint, items}](y/height 为卡内相对坐标)。
300
+ 规则:
301
+ 1. 排除卡外装饰(全屏背景/蒙版:元素在卡 y 轴交集 < 元素高 50%);
302
+ 2. decor-bg(装饰背景层)元素**强制独立成段**(背景层是独立容器,其下内容必须切段);
303
+ 3. 其余按大间隙(gap > 卡高×SEG_GAP_RATIO)或 hint 切换分段。"""
304
+ cx, cy, cw, chh = card_rect
305
+ els = []
306
+ for e in card_els:
307
+ r = e.get("rect")
308
+ if not r or not (r.get("width") or 0):
309
+ continue
310
+ # 卡片容器本身(symbol/shape 尺寸≈整卡)与卡外装饰排除:拓扑只看卡内内容元素
311
+ if e.get("type") == "symbol" and (r.get("width") or 0) >= cw * 0.9 and (r.get("height") or 0) >= chh * 0.9:
312
+ continue
313
+ top = r.get("y", 0)
314
+ bot = top + (r.get("height") or 0)
315
+ inter = max(0, min(bot, cy + chh) - max(top, cy))
316
+ ratio = inter / max((r.get("height") or 1), 1)
317
+ if ratio < 0.5:
318
+ continue # 卡外装饰(全屏背景/蒙版),与卡片拓扑无关
319
+ els.append(e)
320
+ if not els:
321
+ return []
322
+ els = sorted(els, key=lambda e: e["rect"]["y"])
323
+ segs = []
324
+ for e in els:
325
+ r = e["rect"]
326
+ y = r.get("y", 0)
327
+ hint = _segment_hint(e, cw, (cx, cy, cw, chh))
328
+ if hint == "decor-bg":
329
+ # 背景层强制独立成段:与已有 decor-bg 段同 y(<20)合并,否则新开并立即闭合
330
+ if segs and segs[-1]["hint"] == "decor-bg" and abs(segs[-1]["y"] - y) < 20:
331
+ segs[-1]["items"].append(e)
332
+ else:
333
+ segs.append({"y": y, "items": [e], "hint": hint})
334
+ continue
335
+ if not segs or segs[-1]["hint"] == "decor-bg":
336
+ # 背景层(或其前无段)后的内容元素:强制新开内容段——内容叠在背景上,归属不并入背景段
337
+ segs.append({"y": y, "items": [e], "hint": hint})
338
+ continue
339
+ prev_bottom = max((it["rect"]["y"] + (it["rect"]["height"] or 0)) for it in segs[-1]["items"])
340
+ gap = y - prev_bottom
341
+ if gap > chh * SEG_GAP_RATIO or (hint != "content" and hint != segs[-1]["hint"]):
342
+ segs.append({"y": y, "items": [e], "hint": hint})
343
+ continue
344
+ segs[-1]["items"].append(e)
345
+ out = []
346
+ for i, s in enumerate(segs):
347
+ bottom = max((it["rect"]["y"] + (it["rect"]["height"] or 0)) for it in s["items"])
348
+ top = s["y"]
349
+ seg = {
350
+ "y": round(top - cy, 2),
351
+ "height": round(bottom - top, 2),
352
+ "hint": s["hint"],
353
+ "items": len(s["items"]),
354
+ }
355
+ if i == 0:
356
+ seg["gap_top"] = round(top - cy, 2)
357
+ else:
358
+ # 修正坐标混用 bug:统一用卡内相对坐标计算 gap_top
359
+ prev_bottom_rel = out[i - 1]["y"] + out[i - 1]["height"]
360
+ seg["gap_top"] = round(seg["y"] - prev_bottom_rel, 2)
361
+ out.append(seg)
362
+ # 后处理:消除段重叠(前一段底部超出后一段顶部时,截断前一段高度)
363
+ for i in range(1, len(out)):
364
+ overlap = (out[i - 1]["y"] + out[i - 1]["height"]) - out[i]["y"]
365
+ if overlap > 0:
366
+ out[i - 1]["height"] = round(out[i - 1]["height"] - overlap, 2)
367
+ # 重算 gap_top(截断后应 ≥ 0)
368
+ out[i]["gap_top"] = round(out[i]["y"] - (out[i - 1]["y"] + out[i - 1]["height"]), 2)
369
+ return out
370
+
371
+
372
+ # ---------- 画板聚焦:卡片区域 + 裁切 ----------
373
+ def _normalize_radii(raw_radius):
374
+ """把 MeaXure radius 归一化为 [tl,tr,br,bl];无有效值时返回 None。
375
+
376
+ 注意:radius 可能只有 1 项(四角同值)或 4 项(四角独立),也会出现
377
+ 前导 0(如 [0,12,12,12]),必须原样保留 0,不能按「>0」过滤后丢失。
378
+ """
379
+ vals = []
380
+ for v in (raw_radius or []):
381
+ try:
382
+ vals.append(max(0.0, float(v)))
383
+ except (TypeError, ValueError):
384
+ vals.append(0.0)
385
+ if not vals:
386
+ return None
387
+ while len(vals) < 4:
388
+ vals.append(vals[-1] if vals else 0.0)
389
+ return vals[:4]
390
+
391
+
392
+ def _card_mask_info(els, x, y, w, h):
393
+ """从归属元素里找卡片容器,返回 (圆角半径列表, 卡片填充色)。
394
+
395
+ 选择策略只依赖图层几何与样式,**不依赖背景颜色**:
396
+ 1) 与裁切框重叠 ≥60% 的候选中,优先有圆角的元素(卡片外壳通常是带
397
+ borderRadius 的 shape,而非同名 group/symbol 包装);
398
+ 2) 同有圆角时优先有纯色填充,再按重叠度、面积取最大(外壳 > 内部子块)。
399
+ 找不到时返回 (None, fill)。
400
+ """
401
+ best = None
402
+ best_key = None
403
+ for e in els:
404
+ ex, ey, ew, eh = e.rect
405
+ if ew <= 0 or eh <= 0:
406
+ continue
407
+ ix = max(0, min(ex + ew, x + w) - max(ex, x))
408
+ iy = max(0, min(ey + eh, y + h) - max(ey, y))
409
+ overlap = (ix * iy) / (ew * eh)
410
+ if overlap < 0.6:
411
+ continue
412
+ radii = _normalize_radii(e.radius)
413
+ has_radius = 1 if (radii and any(v > 0 for v in radii)) else 0
414
+ fill = extract_fills_style(e.fills).get("color")
415
+ has_fill = 1 if fill else 0
416
+ key = (has_radius, has_fill, overlap, ew * eh)
417
+ if best_key is None or key > best_key:
418
+ best = e
419
+ best_key = key
420
+ if best is None:
421
+ return None, None
422
+ radii = _normalize_radii(best.radius)
423
+ fill = extract_fills_style(best.fills).get("color")
424
+ return radii, fill
425
+
426
+
427
+ def _card_fill_pixel_check(png_path, annotated_fill):
428
+ """cardFill 像素交叉校验(2026-09-06,01基金_基金推荐案例)。
429
+
430
+ 标注 fill 与合成像素可能不一致(如标注 #FFFFFF,Sketch 叠透明度后
431
+ 实际渲染为 #F7F7F7)。采样聚焦图底部 4% 横条(卡片底 padding 区,
432
+ 几乎无内容)中位色,与标注逐通道对比,>6 判冲突。
433
+
434
+ 返回 (sampled_hex|None, conflict|False)。只标记不裁决——
435
+ 标注为权威,冲突由下游 AI 按视觉交叉验证规则裁决。
436
+ """
437
+ if not annotated_fill or not png_path or not os.path.exists(str(png_path)):
438
+ return None, False
439
+ try:
440
+ w, h, channels, rows = png_utils.read_png(str(png_path))
441
+ except Exception:
442
+ return None, False
443
+ y0 = int(h * 0.96)
444
+ samples = [[], [], []]
445
+ for yy in range(y0, h, 2):
446
+ row = rows[yy]
447
+ for xx in range(0, w, 4):
448
+ o = xx * channels
449
+ if channels == 4 and row[o + 3] < 200:
450
+ continue
451
+ samples[0].append(row[o])
452
+ samples[1].append(row[o + 1])
453
+ samples[2].append(row[o + 2])
454
+ if len(samples[0]) < 50:
455
+ return None, False
456
+
457
+ def _median(v):
458
+ v = sorted(v)
459
+ return v[len(v) // 2]
460
+
461
+ sr, sg, sb = _median(samples[0]), _median(samples[1]), _median(samples[2])
462
+ sampled_hex = "#{:02X}{:02X}{:02X}".format(sr, sg, sb)
463
+ m = re.match(r"#?([0-9a-fA-F]{6})", annotated_fill)
464
+ if not m:
465
+ return sampled_hex, False
466
+ hv = m.group(1)
467
+ ar, ag, ab = int(hv[0:2], 16), int(hv[2:4], 16), int(hv[4:6], 16)
468
+ conflict = max(abs(sr - ar), abs(sg - ag), abs(sb - ab)) > 6
469
+ return sampled_hex, conflict
470
+
471
+
472
+ def _deduplicate_shell_elements(els):
473
+ """去重完全同框且不含文本的容器/符号/形状层。
474
+
475
+ MeaXure 常把同一业务控件导出为 symbol、group 和兜底 shape 三层。
476
+ 这些层不是并排组件,而是同一视觉节点的重复标注;保留圆角、填充和
477
+ 子层级最丰富的一层,避免下游把同一控件消费成多个语义块。
478
+ """
479
+ kept = {}
480
+ output = []
481
+ for element in els:
482
+ if element.is_text or element.is_slice:
483
+ output.append(element)
484
+ continue
485
+ x, y, width, height = element.rect
486
+ if width <= 0 or height <= 0:
487
+ output.append(element)
488
+ continue
489
+ key = (element.type, round(x, 2), round(y, 2), round(width, 2), round(height, 2))
490
+ previous = kept.get(key)
491
+ if previous is None:
492
+ kept[key] = element
493
+ output.append(element)
494
+ continue
495
+ fill = extract_fills_style(element.fills).get("color")
496
+ previous_fill = extract_fills_style(previous.fills).get("color")
497
+ priority = (
498
+ 1 if _normalize_radii(element.radius) else 0,
499
+ 1 if fill else 0,
500
+ element.children_count or 0,
501
+ )
502
+ previous_priority = (
503
+ 1 if _normalize_radii(previous.radius) else 0,
504
+ 1 if previous_fill else 0,
505
+ previous.children_count or 0,
506
+ )
507
+ if priority > previous_priority:
508
+ output[output.index(previous)] = element
509
+ kept[key] = element
510
+ return output
511
+
512
+
513
+ def focus_artboard(ab_raw, preview_path, cdir):
514
+ """对一个画板:检测独立卡片区域 → 结构过滤 → 逐一裁切 → 序列化标注。
515
+
516
+ 返回 spec dict(含卡片区域、聚焦截图、样式汇总)。
517
+ """
518
+ W, H = ab_raw.get("width", 0), ab_raw.get("height", 0)
519
+ raw_layers = ab_raw.get("layers") or []
520
+ flattened_layers = vision._flatten_with_tree(raw_layers)
521
+ overlay = vision.detect_overlay_dialog(vision._flatten_with_tree(raw_layers), W, H)
522
+ bottom_sheet = vision._bottom_sheet_rect(raw_layers, W, H)
523
+ conversation_flow = vision.detect_conversation_flow(flattened_layers, W, H)
524
+ if overlay or bottom_sheet:
525
+ cards = []
526
+ focus_skip_reason = (
527
+ "overlay_dialog_geometry" if overlay else "bottom_sheet_geometry"
528
+ )
529
+ else:
530
+ cards = vision.detect_cards(raw_layers, W, H, conversation_flow)
531
+ focus_skip_reason = ""
532
+
533
+ for old_png in cdir.glob("card-*.png"):
534
+ old_png.unlink()
535
+
536
+ # 归属元素(卡片聚焦后的扁平元素;层级树已废弃,拓扑由下游几何重建)
537
+ elements = []
538
+ for ve in vision._flatten_with_tree(raw_layers):
539
+ elements.append(serialize_element(ve, ve.raw))
540
+
541
+ card_regions = []
542
+ rejected_regions = []
543
+ eligible_cards = []
544
+ for x, y, w, h, els, shapes in cards:
545
+ rect = (x, y, w, h)
546
+ eligible, reject_reason, focus_metrics = vision.classify_card_focus(
547
+ els, rect, W, H, conversation_flow)
548
+ if not eligible:
549
+ rejected_regions.append({
550
+ "rect": {"x": round(x, 2), "y": round(y, 2),
551
+ "width": round(w, 2), "height": round(h, 2)},
552
+ "reason": reject_reason,
553
+ "metrics": focus_metrics,
554
+ })
555
+ continue
556
+ eligible_cards.append((x, y, w, h, els, shapes, focus_metrics))
557
+
558
+ from build_prompt import detect_list_in_card # 列表检测复用 build_prompt(无循环依赖)
559
+ for i, (x, y, w, h, els, shapes, focus_metrics) in enumerate(eligible_cards, 1):
560
+ region = {"index": i, "rect": {"x": round(x, 2), "y": round(y, 2),
561
+ "width": round(w, 2), "height": round(h, 2)},
562
+ "trainingEligible": True,
563
+ "focusMetrics": focus_metrics}
564
+ mask_radii, card_fill = _card_mask_info(els, x, y, w, h)
565
+ region["cardFill"] = card_fill
566
+ region["mask"] = {
567
+ # 遮罩完全来自卡片几何(rect+radius),与背景是否均匀/固定颜色无关。
568
+ "type": "geometry",
569
+ "radius": mask_radii or None,
570
+ "shapeCount": len(shapes),
571
+ }
572
+ # 裁切局部聚焦图(@2x,RGBA:卡片几何外的背景透明)
573
+ if preview_path and preview_path.exists():
574
+ out_png = cdir / f"card-{i}.png"
575
+ try:
576
+ png_utils.crop_png_masked(str(preview_path), (x, y, w, h), str(out_png), PREVIEW_SCALE, shapes)
577
+ region["image"] = out_png.name
578
+ sampled_fill, fill_conflict = _card_fill_pixel_check(out_png, card_fill)
579
+ if sampled_fill:
580
+ region["cardFillSampled"] = sampled_fill
581
+ if fill_conflict:
582
+ region["cardFillConflict"] = True
583
+ print(f" [WARN] {region['index']}. cardFill 标注 {card_fill} vs 采样 {sampled_fill} 通道差>6,已标记 cardFillConflict(下游按视觉裁决)", file=sys.stderr)
584
+ except Exception as e: # noqa
585
+ print(f" [WARN] 裁切失败 {out_png.name}: {e}", file=sys.stderr)
586
+ # 归属元素(卡片聚焦后的扁平元素;层级树已废弃,拓扑由下游几何重建)
587
+ # _flatten_with_tree 填的 parentId 恒为 None(vision.py:168 walk(raw_layers, None, 0) 仅在 L["layers"] 存在时递归)。
588
+ focused_elements = _deduplicate_shell_elements(els)
589
+ region["elements"] = [serialize_element(e, e.raw) for e in focused_elements] or []
590
+ # 从扁平元素重建嵌套层级树(供 AI 读图理解容器↔子元素关系)
591
+ # Flex 布局推断(容器主排列方向与对齐,纯几何)
592
+ child_rects = [(e.rect[0], e.rect[1], e.rect[2], e.rect[3])
593
+ for e in focused_elements if not e.is_text and not e.is_slice
594
+ and e.rect[2] > 0 and e.rect[3] > 0
595
+ and e.rect[2] * e.rect[3] < 0.7 * w * h]
596
+ flex = vision.infer_flex_layout((x, y, w, h), child_rects)
597
+ if flex:
598
+ region["flexLayout"] = flex
599
+ # 视觉分段(几何聚类,供下游拓扑生成;group 结构不作拓扑依据)
600
+ region["segments"] = detect_segments(region["elements"], (x, y, w, h))
601
+ # 列表检测(纯几何同构)回写契约,供下游 v3/geo 直接消费,避免重猜
602
+ region["detectedList"] = detect_list_in_card(region) or None
603
+ card_regions.append(region)
604
+
605
+ # Fallback(2026-08-31):视觉检测 0 个独立卡片区域时,仍裁一张图 card-1.png,
606
+ # 保证每个普通画板都有 card-* 截图(下游聚类/训练参考用)。不写入 card_regions,
607
+ # 避免误触发下游卡片生成(spec.cards 仍空 → status 维持 skipped/generated)。
608
+ # 仅对普通画板生效;overlay_dialog / bottom_sheet 是弹框,不应裁卡截图。
609
+ # 会话流画板(气泡/输入坞/键盘)同样不产兜底图:整屏截图含对话气泡与纯文本,
610
+ # 易被误读为「把整屏做成了卡片」,且 artboard.png 已可承担参考职责。
611
+ # 裁切去掉系统 chrome(状态栏 / Home Indicator),保留应用 UI(顶部导航/底部 tab),
612
+ # 对齐「仅卡片主体」约定。偏移按设计稿高度粗判(刘海屏状态栏 44、老屏 20;
613
+ # Home Indicator 仅刘海屏 34),均为设计 px,crop_png_masked 内部乘 PREVIEW_SCALE。
614
+ if (not card_regions and not overlay and not bottom_sheet
615
+ and not conversation_flow
616
+ and preview_path and preview_path.exists()):
617
+ try:
618
+ notch = H >= 800
619
+ top_trim = min(44 if notch else 20, H // 4)
620
+ bottom_trim = min(34 if notch else 0, H // 4)
621
+ rect = (0, top_trim, W, H - top_trim - bottom_trim)
622
+ out_png = cdir / "card-1.png"
623
+ png_utils.crop_png_masked(str(preview_path), rect, str(out_png), PREVIEW_SCALE, [])
624
+ print(f" [fallback] {cdir.name}: 0 卡检测 → 裁切 card-1.png (去chrome top={top_trim} bottom={bottom_trim})", file=sys.stderr)
625
+ except Exception as e: # noqa
626
+ print(f" [WARN] fallback 裁切失败 {out_png.name}: {e}", file=sys.stderr)
627
+
628
+ if conversation_flow and not card_regions:
629
+ focus_skip_reason = "conversation_flow_geometry"
630
+
631
+ spec = {
632
+ "canvas": ab_raw.get("name", ""),
633
+ "schemaVersion": "1.0",
634
+ "size": {"width": W, "height": H},
635
+ "cards": card_regions,
636
+ "rejectedCards": rejected_regions,
637
+ "conversationFlow": conversation_flow,
638
+ "focusSkipReason": focus_skip_reason or (
639
+ "no_structural_card_region" if not card_regions else ""
640
+ ),
641
+ "elements": elements,
642
+ "styleSummary": summarize_styles(elements),
643
+ "slices": collect_slice_names(raw_layers),
644
+ }
645
+ return spec
646
+
647
+
648
+ def collect_slice_names(raw_layers):
649
+ out = []
650
+ def walk(ls):
651
+ for L in ls:
652
+ if L.get("type") == "slice":
653
+ out.append(L.get("name", ""))
654
+ if L.get("layers"):
655
+ walk(L["layers"])
656
+ walk(raw_layers)
657
+ return out
658
+
659
+
660
+ def summarize_styles(elements):
661
+ """聚合该状态全部元素的平台对齐样式(去重)。"""
662
+ colors, gradients, fonts, radius, shadows = [], [], [], [], []
663
+ for e in elements:
664
+ st = e.get("style") or {}
665
+ bkg = st.get("background") or {}
666
+ if bkg.get("color"):
667
+ colors.append(bkg["color"])
668
+ if bkg.get("gradient"):
669
+ gradients.append(bkg["gradient"])
670
+ ts = e.get("textStyle") or {}
671
+ if ts.get("color"):
672
+ colors.append(ts["color"])
673
+ if ts.get("fontSize") and ts.get("fontSize") not in [f.get("size") for f in fonts]:
674
+ fonts.append({"size": ts["fontSize"], "fontFace": ts.get("fontFace"),
675
+ "fontWeight": ts.get("fontWeight"), "lineHeight": ts.get("lineHeight"),
676
+ "letterSpacing": ts.get("letterSpacing"),
677
+ "textAlign": ts.get("textAlign")})
678
+ r = st.get("radius")
679
+ if r and r.get("borderRadius") not in [x.get("borderRadius") for x in radius]:
680
+ radius.append(r)
681
+ s = st.get("shadow")
682
+ if s and s.get("isShadow") and s not in shadows:
683
+ shadows.append(s)
684
+ return {
685
+ "colors": sorted(set(c for c in colors if c)),
686
+ "gradients": sorted(set(g for g in gradients if g)),
687
+ "fonts": fonts,
688
+ "borderRadius": radius,
689
+ "shadows": shadows,
690
+ }
691
+
692
+
693
+ # ---------- 切图匹配 ----------
694
+ def build_asset_index(assets_dir):
695
+ index = {}
696
+ if not assets_dir.exists():
697
+ return index
698
+ for p in assets_dir.rglob("*"):
699
+ if p.is_file():
700
+ stem = re.sub(r"@\dx$", "", p.stem, flags=re.IGNORECASE)
701
+ index.setdefault(stem, []).append(p.relative_to(assets_dir).as_posix())
702
+ return index
703
+
704
+
705
+ def norm(name):
706
+ s = name.rsplit("/", 1)[-1]
707
+ s = re.sub(r"@\dx$", "", s, flags=re.IGNORECASE)
708
+ s = re.sub(r"_icon$", "", s, flags=re.IGNORECASE)
709
+ return s.strip().lower()
710
+
711
+
712
+ def _slice_scale(rel):
713
+ """从切图文件名提取倍率:@3x→3, @2x→2, @1x→1, 无后缀→0。用于同名取最高倍率。"""
714
+ stem = re.sub(r"\.(png|jpg|svg|webp)$", "", rel, flags=re.IGNORECASE)
715
+ m = re.search(r"@(\d)x$", stem, re.IGNORECASE)
716
+ return int(m.group(1)) if m else 0
717
+
718
+
719
+ def _asset_path_tokens(value):
720
+ value = re.sub(r"[\\/、.]+", "/", value).lower()
721
+ value = re.sub(r"@\d+x", "", value, flags=re.IGNORECASE)
722
+ stem = re.sub(r"@\dx$", "", value, flags=re.IGNORECASE)
723
+ stem = re.sub(r"\.(png|jpg|jpeg|webp|svg)$", "", stem, flags=re.IGNORECASE)
724
+ return {part.strip() for part in stem.split("/") if part.strip()}
725
+
726
+
727
+ def _asset_candidate_score(relative_path, canvas_slice_names):
728
+ relative_tokens = _asset_path_tokens(relative_path)
729
+ name_tokens = set().union(*(_asset_path_tokens(name) for name in canvas_slice_names))
730
+ filename = relative_path.rsplit("/", 1)[-1].lower()
731
+ extension = filename.rsplit(".", 1)[-1]
732
+ directory = relative_path.rsplit("/", 1)[0] if "/" in relative_path else ""
733
+ return (
734
+ len(relative_tokens & name_tokens),
735
+ int(extension in {"png", "jpg", "jpeg", "webp"}),
736
+ _slice_scale(relative_path),
737
+ len(relative_tokens),
738
+ )
739
+
740
+
741
+ AUTO_LAYER_NAME_RE = re.compile(
742
+ r"^(编组|组|group|layer|矩形|rectangle|shape|path|椭圆|oval|直线|line|星形|star|多边形|polygon)\s*\d+$",
743
+ re.IGNORECASE,
744
+ )
745
+
746
+
747
+ def is_auto_layer_name(name):
748
+ """Sketch 自动命名(编组 N / 矩形 N / Group N…):尾部数字是唯一标识,宽松匹配会错配资产。"""
749
+ return bool(AUTO_LAYER_NAME_RE.match(str(name).rsplit("/", 1)[-1].strip()))
750
+
751
+
752
+ def collect_slice_files(canvas_slice_names, asset_index):
753
+ """匹配画板引用的切图并按同名取最高倍率去重。
754
+ 规则:
755
+ 1. 精确匹配:同名切图(剥 @Nx 后缀后同名)若有多尺寸版本,只保留最高倍率(@3x>@2x>@1x>原始)。
756
+ 例:顶部返回.png / @1x.svg / @2x.png / @3x.png → 只保留 @3x.png。
757
+ 2. 宽松匹配(MeaXure 导出文件名常与图层名不一致):精确未命中时,用图层名的「语义段」
758
+ (去 8.icon/ 前缀、去尾部 -N/_N 数字后缀)匹配文件**路径目录**(如 8.icon/成都农商银行logo-26
759
+ 的语义段 成都农商银行logo ∈ 文件 8.icon/成都农商银行logo/单logo26@3x.png 的目录)。
760
+ 解决「图层有 slice 标注但导出文件名不同」导致切图静默丢失。
761
+ """
762
+ all_files = [rel for rels in asset_index.values() for rel in rels]
763
+ by_name = defaultdict(list)
764
+ for w in {norm(n) for n in canvas_slice_names}:
765
+ if w in asset_index:
766
+ by_name[w].extend(asset_index[w])
767
+ # 精确未命中的图层 → 宽松匹配:
768
+ # a) 语义段 ∈ 目录(成都农商银行logo-26 → 8.icon/成都农商银行logo/单logo26@3x.png)
769
+ # b) 去尾部数字后 basename 相等(成功1 → 成功 / 顶部-弥散 → 顶部-弥散1,MeaXure 序号变体)
770
+ for n in canvas_slice_names:
771
+ nk = norm(n)
772
+ if by_name.get(nk):
773
+ continue
774
+ # 自动命名图层只认精确匹配:编组 2 与 编组 4.png 仅尾部数字不同,
775
+ # 去数字宽松匹配会把错误资产绑给图层(黄色行标曾整批错配/丢失)。
776
+ if is_auto_layer_name(n):
777
+ continue
778
+ base = n.rsplit("/", 1)[-1]
779
+ core = re.sub(r"[-_]\d+$", "", base).strip().lower()
780
+ base_n = re.sub(r"[-_ ]?\d+$", "", base).strip().lower()
781
+ for rel in all_files:
782
+ fn = rel.rsplit("/", 1)[-1]
783
+ fn_core = fn
784
+ fn_core = re.sub(r"\.[a-z0-9]+$", "", fn_core, flags=re.IGNORECASE)
785
+ fn_core = re.sub(r"@\dx$", "", fn_core, flags=re.IGNORECASE)
786
+ fn_n = re.sub(r"[-_ ]?\d+$", "", fn_core).strip().lower()
787
+ dirpart = rel.rsplit("/", 1)[0].lower() if "/" in rel else ""
788
+ if (dirpart and len(core) >= 2 and core in dirpart) \
789
+ or (len(base_n) >= 2 and fn_n == base_n):
790
+ by_name[nk].append(rel)
791
+ # 同名取最高倍率
792
+ matched = []
793
+ for w, rels in by_name.items():
794
+ best = max(rels, key=lambda rel: _asset_candidate_score(rel, canvas_slice_names))
795
+ matched.append(best)
796
+ return sorted(matched)
797
+
798
+
799
+ def _mode_rgb(pixels):
800
+ counts = defaultdict(int)
801
+ for pixel in pixels:
802
+ counts[pixel] += 1
803
+ return max(counts, key=counts.get) if counts else None
804
+
805
+
806
+ def sample_foreground_color(image_path, rect, scale):
807
+ """从设计稿整图采样切片区域的前景近似纯色。
808
+
809
+ 背景取边界众数;前景取与背景色距足够的内部像素众数。阈值是通用感知分离
810
+ 参数,不包含任何业务或设计风格假设。采样失败返回 None。
811
+ """
812
+ try:
813
+ width, height, channels, rows = png_utils.read_png(image_path)
814
+ except (OSError, ValueError):
815
+ return None
816
+
817
+ x0 = max(0, int(round(float(rect.get("x", 0)) * scale)))
818
+ y0 = max(0, int(round(float(rect.get("y", 0)) * scale)))
819
+ x1 = min(width, int(round((float(rect.get("x", 0)) + float(rect.get("width", 0))) * scale)))
820
+ y1 = min(height, int(round((float(rect.get("y", 0)) + float(rect.get("height", 0))) * scale)))
821
+ if x1 - x0 < 2 or y1 - y0 < 2:
822
+ return None
823
+
824
+ def pixel(x, y):
825
+ x = max(0, min(width - 1, x))
826
+ y = max(0, min(height - 1, y))
827
+ offset = x * channels
828
+ if channels == 1:
829
+ return (rows[y][offset], rows[y][offset], rows[y][offset])
830
+ return (rows[y][offset], rows[y][offset + 1], rows[y][offset + 2])
831
+
832
+ def opaque(x, y):
833
+ return channels != 4 or rows[y][x * channels + 3] >= 16
834
+
835
+ border = []
836
+ for x in range(x0, x1):
837
+ for y in (y0, y1 - 1):
838
+ if opaque(x, y):
839
+ border.append(pixel(x, y))
840
+ for y in range(y0 + 1, y1 - 1):
841
+ for x in (x0, x1 - 1):
842
+ if opaque(x, y):
843
+ border.append(pixel(x, y))
844
+ background = _mode_rgb(border)
845
+ if background is None:
846
+ return None
847
+
848
+ foreground = defaultdict(list)
849
+ for y in range(y0 + 1, y1 - 1):
850
+ for x in range(x0 + 1, x1 - 1):
851
+ if not opaque(x, y):
852
+ continue
853
+ rgb = pixel(x, y)
854
+ distance = sum((left - right) ** 2 for left, right in zip(rgb, background))
855
+ if distance < 36 ** 2:
856
+ continue
857
+ foreground[(rgb[0] >> 3 << 3, rgb[1] >> 3 << 3, rgb[2] >> 3 << 3)].append(rgb)
858
+ if foreground:
859
+ bucket = max(foreground, key=lambda key: len(foreground[key]))
860
+ samples = foreground[bucket]
861
+ rgb = tuple(sum(sample[index] for sample in samples) // len(samples) for index in range(3))
862
+ return "#%02X%02X%02X" % rgb
863
+
864
+ center_x0 = (x0 + x1 - 1) // 2
865
+ center_y0 = (y0 + y1 - 1) // 2
866
+ center_x1, center_y1 = center_x0 + 2, center_y0 + 2
867
+ center = [pixel(x, y) for y in range(center_y0, center_y1) for x in range(center_x0, center_x1) if opaque(x, y)]
868
+ rgb = _mode_rgb(center)
869
+ return "#%02X%02X%02X" % rgb if rgb else None
870
+
871
+
872
+ def sample_background_color(image_path, rect, scale):
873
+ """采样切片区域的边界主导色,用于缺失导出的大面积背景兜底。"""
874
+ try:
875
+ width, height, channels, rows = png_utils.read_png(image_path)
876
+ except (OSError, ValueError):
877
+ return None
878
+
879
+ x0 = max(0, int(round(float(rect.get("x", 0)) * scale)))
880
+ y0 = max(0, int(round(float(rect.get("y", 0)) * scale)))
881
+ x1 = min(width, int(round((float(rect.get("x", 0)) + float(rect.get("width", 0))) * scale)))
882
+ y1 = min(height, int(round((float(rect.get("y", 0)) + float(rect.get("height", 0))) * scale)))
883
+ if x1 - x0 < 2 or y1 - y0 < 2:
884
+ return None
885
+
886
+ def pixel(x, y):
887
+ offset = x * channels
888
+ if channels == 1:
889
+ return (rows[y][offset], rows[y][offset], rows[y][offset])
890
+ return (rows[y][offset], rows[y][offset + 1], rows[y][offset + 2])
891
+
892
+ def opaque(x, y):
893
+ return channels != 4 or rows[y][x * channels + 3] >= 16
894
+
895
+ border = []
896
+ for x in range(x0, x1):
897
+ for y in (y0, y1 - 1):
898
+ if opaque(x, y):
899
+ border.append(pixel(x, y))
900
+ for y in range(y0 + 1, y1 - 1):
901
+ for x in (x0, x1 - 1):
902
+ if opaque(x, y):
903
+ border.append(pixel(x, y))
904
+ rgb = _mode_rgb(border)
905
+ return "#%02X%02X%02X" % rgb if rgb else None
906
+
907
+
908
+ def _mask_pixels(image_path, rect=None, scale=1.0, cells=16):
909
+ """生成小型媒体的前景掩码;透明素材用 alpha,裁切区域用背景色距。"""
910
+ try:
911
+ width, height, channels, rows = png_utils.read_png(image_path)
912
+ except (OSError, ValueError):
913
+ return []
914
+
915
+ if rect:
916
+ x0 = int(round(float(rect.get("x", 0)) * scale))
917
+ y0 = int(round(float(rect.get("y", 0)) * scale))
918
+ x1 = int(round((float(rect.get("x", 0)) + float(rect.get("width", 0))) * scale))
919
+ y1 = int(round((float(rect.get("y", 0)) + float(rect.get("height", 0))) * scale))
920
+ else:
921
+ x0, y0, x1, y1 = 0, 0, width, height
922
+ x0, y0 = max(0, x0), max(0, y0)
923
+ x1, y1 = min(width, x1), min(height, y1)
924
+ if x1 - x0 < 2 or y1 - y0 < 2:
925
+ return []
926
+ if any(len(row) < width * channels for row in rows):
927
+ return []
928
+
929
+ def pixel(x, y):
930
+ offset = x * channels
931
+ if channels >= 3:
932
+ alpha = rows[y][offset + 3] if channels == 4 else 255
933
+ return rows[y][offset], rows[y][offset + 1], rows[y][offset + 2], alpha
934
+ if channels == 1:
935
+ return rows[y][offset], rows[y][offset], rows[y][offset], 255
936
+ return 255, 255, 255, 255
937
+
938
+ border = []
939
+ for x in range(x0, x1):
940
+ border.extend((pixel(x, y0), pixel(x, y1 - 1)))
941
+ for y in range(y0 + 1, y1 - 1):
942
+ border.extend((pixel(x0, y), pixel(x1 - 1, y)))
943
+ background = max(set(border), key=border.count)
944
+
945
+ mask = []
946
+ try:
947
+ for cell_y in range(cells):
948
+ for cell_x in range(cells):
949
+ x = round(x0 + (cell_x + 0.5) * (x1 - x0) / cells)
950
+ y = round(y0 + (cell_y + 0.5) * (y1 - y0) / cells)
951
+ red, green, blue, alpha = pixel(x, y)
952
+ if channels == 4:
953
+ mask.append(alpha >= 64)
954
+ else:
955
+ distance = math.sqrt(
956
+ (red - background[0]) ** 2 +
957
+ (green - background[1]) ** 2 +
958
+ (blue - background[2]) ** 2,
959
+ )
960
+ mask.append(distance > 48)
961
+ except (IndexError, ValueError):
962
+ return []
963
+ return mask
964
+
965
+
966
+ def _mask_iou(left, right):
967
+ if not left or not right:
968
+ return 1.0
969
+ union = sum(1 for left_pixel, right_pixel in zip(left, right)
970
+ if left_pixel or right_pixel)
971
+ intersection = sum(1 for left_pixel, right_pixel in zip(left, right)
972
+ if left_pixel and right_pixel)
973
+ return intersection / union if union else 1.0
974
+
975
+
976
+ def _color_cell_samples(image_path, rect=None, scale=1.0, cells=16):
977
+ """按相对位置采样 RGB,不整图缩放;rect 为空时采样整张资产。"""
978
+ width, height, channels, rows = png_utils.read_png(image_path)
979
+ if not width or not height or channels < 3:
980
+ return []
981
+ if rect is None:
982
+ x0, y0, x1, y1 = 0, 0, width, height
983
+ else:
984
+ x0 = int(round(float(rect.get("x", 0)) * scale))
985
+ y0 = int(round(float(rect.get("y", 0)) * scale))
986
+ x1 = x0 + max(1, int(round(float(rect.get("width", 0)) * scale)))
987
+ y1 = y0 + max(1, int(round(float(rect.get("height", 0)) * scale)))
988
+ x0, y0 = max(0, x0), max(0, y0)
989
+ x1, y1 = min(width, x1), min(height, y1)
990
+ if x1 <= x0 or y1 <= y0:
991
+ return []
992
+
993
+ def sample(x, y):
994
+ offset = min(width - 1, max(0, x)) * channels
995
+ row = rows[min(height - 1, max(0, y))]
996
+ if channels == 4:
997
+ alpha = row[offset + 3]
998
+ if alpha < 16:
999
+ return None
1000
+ if alpha < 255:
1001
+ # 半透明边沿像素先按白底合成再比色:透明底图标的原始 RGB
1002
+ # 与设计稿(图标已叠在浅色卡底上)不可直接比较,否则描边/
1003
+ # 抗锯齿像素会被系统性误判为颜色冲突,触发不必要的裁切回退。
1004
+ blend = alpha / 255.0
1005
+ return tuple(
1006
+ round(channel * blend + 255 * (1 - blend))
1007
+ for channel in row[offset:offset + 3]
1008
+ )
1009
+ return row[offset], row[offset + 1], row[offset + 2]
1010
+
1011
+ samples = []
1012
+ for cell_y in range(cells):
1013
+ for cell_x in range(cells):
1014
+ x = round(x0 + (cell_x + 0.5) * (x1 - x0) / cells)
1015
+ y = round(y0 + (cell_y + 0.5) * (y1 - y0) / cells)
1016
+ samples.append(sample(x, y))
1017
+ return samples
1018
+
1019
+
1020
+ def slice_color_similarity(asset_path, preview_path, rect, scale):
1021
+ """比较切片与设计裁切的 RGB 一致性,捕捉 mask IoU 漏掉的底色/渐变冲突。"""
1022
+ asset_samples = _color_cell_samples(asset_path)
1023
+ design_samples = _color_cell_samples(preview_path, rect, scale)
1024
+ if not asset_samples or not design_samples:
1025
+ return 1.0
1026
+ score = 0.0
1027
+ compared = 0
1028
+ for asset_pixel, design_pixel in zip(asset_samples, design_samples):
1029
+ if asset_pixel is None or design_pixel is None:
1030
+ continue
1031
+ score += sum(
1032
+ 1 for left, right in zip(asset_pixel, design_pixel)
1033
+ if abs(left - right) <= 18
1034
+ ) / 3
1035
+ compared += 1
1036
+ return score / compared if compared else 1.0
1037
+
1038
+
1039
+ def slice_visual_similarity(asset_path, preview_path, rect, scale):
1040
+ """比较切片素材与设计稿同位裁切的前景形状,供标注冲突检测用。"""
1041
+ return _mask_iou(
1042
+ _mask_pixels(asset_path, cells=16),
1043
+ _mask_pixels(preview_path, rect, scale, 16),
1044
+ )
1045
+
1046
+
1047
+ def _foreground_mean_color(image_path, rect=None, scale=1.0):
1048
+ """前景像素(资产按 alpha、裁切按边框背景距)白底合成后的均值色。
1049
+
1050
+ 细描边小图标在 16×16 栅格上可比的共同样本极少(常 <12 格),逐格
1051
+ 比色天然不稳;形状已确认一致时改用前景均值色做色相校验更稳健。
1052
+ """
1053
+ try:
1054
+ width, height, channels, rows = png_utils.read_png(image_path)
1055
+ except (OSError, ValueError):
1056
+ return None, 0
1057
+ if not width or not height or channels < 3:
1058
+ return None, 0
1059
+ if rect is None:
1060
+ x0, y0, x1, y1 = 0, 0, width, height
1061
+ else:
1062
+ x0 = int(round(float(rect.get("x", 0)) * scale))
1063
+ y0 = int(round(float(rect.get("y", 0)) * scale))
1064
+ x1 = int(round((float(rect.get("x", 0)) + float(rect.get("width", 0))) * scale))
1065
+ y1 = int(round((float(rect.get("y", 0)) + float(rect.get("height", 0))) * scale))
1066
+ x0, y0 = max(0, x0), max(0, y0)
1067
+ x1, y1 = min(width, x1), min(height, y1)
1068
+ if x1 - x0 < 2 or y1 - y0 < 2:
1069
+ return None, 0
1070
+
1071
+ def pixel(x, y):
1072
+ offset = x * channels
1073
+ alpha = rows[y][offset + 3] if channels == 4 else 255
1074
+ return rows[y][offset], rows[y][offset + 1], rows[y][offset + 2], alpha
1075
+
1076
+ background = None
1077
+ if channels != 4:
1078
+ border = []
1079
+ for x in range(x0, x1):
1080
+ border.extend((pixel(x, y0), pixel(x, y1 - 1)))
1081
+ for y in range(y0 + 1, y1 - 1):
1082
+ border.extend((pixel(x0, y), pixel(x1 - 1, y)))
1083
+ background = max(set(border), key=border.count)
1084
+
1085
+ red = green = blue = count = 0
1086
+ for y in range(y0, y1):
1087
+ for x in range(x0, x1):
1088
+ r, g, b, alpha = pixel(x, y)
1089
+ if channels == 4:
1090
+ if alpha < 64:
1091
+ continue
1092
+ blend = alpha / 255.0
1093
+ r = r * blend + 255 * (1 - blend)
1094
+ g = g * blend + 255 * (1 - blend)
1095
+ b = b * blend + 255 * (1 - blend)
1096
+ else:
1097
+ distance = math.sqrt(
1098
+ (r - background[0]) ** 2 +
1099
+ (g - background[1]) ** 2 +
1100
+ (b - background[2]) ** 2,
1101
+ )
1102
+ if distance <= 48:
1103
+ continue
1104
+ red += r
1105
+ green += g
1106
+ blue += b
1107
+ count += 1
1108
+ if not count:
1109
+ return None, 0
1110
+ return (red / count, green / count, blue / count), count
1111
+
1112
+
1113
+ def foreground_color_distance(asset_path, preview_path, rect, scale):
1114
+ """资产与设计裁切的前景均值色距离;任一侧无前景时返回 None。"""
1115
+ asset_mean, asset_count = _foreground_mean_color(asset_path)
1116
+ design_mean, design_count = _foreground_mean_color(preview_path, rect, scale)
1117
+ if not asset_mean or not design_mean:
1118
+ return None
1119
+ return math.sqrt(sum(
1120
+ (left - right) ** 2 for left, right in zip(asset_mean, design_mean)
1121
+ ))
1122
+
1123
+
1124
+ def foreground_chroma_distance(asset_path, preview_path, rect, scale):
1125
+ """前景均值色的色度距离(r-g / b-g 两分量)。
1126
+
1127
+ 抗锯齿/分辨率差只把颜色沿「通向白色」的方向混合,色度近似不变;
1128
+ 真实内容差(黄 vs 灰、蓝 vs 黑)色度差异显著。返回
1129
+ (chroma_distance, luma_distance),任一侧无前景时为 None。
1130
+ """
1131
+ asset_mean, _ = _foreground_mean_color(asset_path)
1132
+ design_mean, _ = _foreground_mean_color(preview_path, rect, scale)
1133
+ if not asset_mean or not design_mean:
1134
+ return None
1135
+ chroma = math.sqrt(
1136
+ ((asset_mean[0] - asset_mean[1]) - (design_mean[0] - design_mean[1])) ** 2 +
1137
+ ((asset_mean[2] - asset_mean[1]) - (design_mean[2] - design_mean[1])) ** 2,
1138
+ )
1139
+ luma = math.sqrt(sum(
1140
+ (left - right) ** 2 for left, right in zip(asset_mean, design_mean)
1141
+ ))
1142
+ return chroma, luma
1143
+
1144
+
1145
+ def mark_slice_visual_mismatch(element, asset_path, preview_path, rect, scale, defects):
1146
+ """用像素形状交叉验证切片标注;冲突时保留画板像素并记录标注缺陷。"""
1147
+ similarity = slice_visual_similarity(asset_path, preview_path, rect, scale)
1148
+ color_similarity = slice_color_similarity(asset_path, preview_path, rect, scale)
1149
+ element["visualIdentity"] = round((similarity + color_similarity) / 2, 3)
1150
+ element["colorIdentity"] = round(color_similarity, 3)
1151
+ if similarity >= 0.35 and color_similarity >= 0.75:
1152
+ return False
1153
+ # 浅色细描边图标救援:16×16 逐格比色 ≥0.9 本身就是形状+颜色的双重
1154
+ # 一致(错误内容不可能逐格同色),此时前景掩码对浅灰描边不可靠,
1155
+ # IoU 低不应否决。
1156
+ if color_similarity >= 0.9:
1157
+ return False
1158
+ # 细描边小图标救援:形状一致(IoU≥0.5)但逐格比色样本过少不稳时,
1159
+ # 以前景色度复核:抗锯齿只把描边色向白色混合,色度近似不变
1160
+ # (真彩差异色度 ≫25);亮度差兜底拦截同色度但明暗悬殊的内容。
1161
+ if similarity >= 0.5:
1162
+ chroma_check = foreground_chroma_distance(
1163
+ asset_path, preview_path, rect, scale)
1164
+ if chroma_check is not None \
1165
+ and chroma_check[0] <= 25 and chroma_check[1] <= 100:
1166
+ element["foregroundColorDistance"] = round(chroma_check[0], 1)
1167
+ element["visualIdentity"] = round(similarity, 3)
1168
+ return False
1169
+ defect = element.get("annotationDefect") or {
1170
+ "type": "missing-slice",
1171
+ "name": element.get("name", ""),
1172
+ "reason": "exported slice identity is unconfirmed",
1173
+ }
1174
+ defect.update({
1175
+ "type": "slice-visual-mismatch",
1176
+ "reason": "exported slice pixels do not match design crop",
1177
+ "visualSimilarity": round(similarity, 3),
1178
+ "colorSimilarity": round(color_similarity, 3),
1179
+ })
1180
+ element["annotationDefect"] = defect
1181
+ defects.append(defect)
1182
+ return True
1183
+
1184
+
1185
+ def mark_missing_slice_elements(spec, preview_path, asset_index, asset_root=None, crop_dir=None):
1186
+ """为缺失或视觉冲突媒体记录缺陷;优先用画板局部裁切,失败才用占位图。"""
1187
+ defects = []
1188
+ for element in spec.get("elements") or []:
1189
+ element.pop("annotationDefect", None)
1190
+ element.pop("visualIdentity", None)
1191
+ for card in spec.get("cards") or []:
1192
+ for element in card.get("elements") or []:
1193
+ element.pop("annotationDefect", None)
1194
+ element.pop("visualIdentity", None)
1195
+ spec.pop("annotationDefects", None)
1196
+ image_width, _, _, _ = png_utils.read_png(preview_path)
1197
+ design_width = float((spec.get("size") or {}).get("width") or 375)
1198
+ scale = image_width / design_width if design_width else PREVIEW_SCALE
1199
+
1200
+ def infer_card_rect(elements):
1201
+ canvas_rect = spec.get("size") or {}
1202
+ canvas_area = max(float(canvas_rect.get("width", 0)) * float(canvas_rect.get("height", 0)), 1)
1203
+ content = []
1204
+ for element in elements:
1205
+ rect = element.get("rect") or {}
1206
+ width = float(rect.get("width", 0))
1207
+ height = float(rect.get("height", 0))
1208
+ if width * height >= canvas_area * 0.8:
1209
+ continue
1210
+ content.append(rect)
1211
+ if not content:
1212
+ return canvas_rect
1213
+ return {
1214
+ "x": min(float(rect.get("x", 0)) for rect in content),
1215
+ "y": min(float(rect.get("y", 0)) for rect in content),
1216
+ "width": max(float(rect.get("x", 0)) + float(rect.get("width", 0)) for rect in content) -
1217
+ min(float(rect.get("x", 0)) for rect in content),
1218
+ "height": max(float(rect.get("y", 0)) + float(rect.get("height", 0)) for rect in content) -
1219
+ min(float(rect.get("y", 0)) for rect in content),
1220
+ }
1221
+
1222
+ def append_defect(element, card_rect):
1223
+ rect = element.get("rect") or {}
1224
+ width = float(rect.get("width", 0))
1225
+ height = float(rect.get("height", 0))
1226
+ card_area = max(float(card_rect.get("width", 0)) * float(card_rect.get("height", 0)), 1)
1227
+ background_like = width >= float(card_rect.get("width", 0)) * 0.5 or width * height >= 10000
1228
+ color = sample_background_color(preview_path, rect, scale) if background_like \
1229
+ else sample_foreground_color(preview_path, rect, scale)
1230
+ defect = {
1231
+ "type": "background-slice-unavailable" if background_like else "missing-slice",
1232
+ "name": element.get("name", ""),
1233
+ "reason": "media annotation has no exported asset",
1234
+ }
1235
+ if color:
1236
+ style = element.setdefault("style", {})
1237
+ for group_key in ("fill", "background"):
1238
+ group = style.setdefault(group_key, {})
1239
+ group["color"] = color
1240
+ defect["approximateSolidColor"] = color
1241
+ defect["resolution"] = "sampled-solid-color"
1242
+ else:
1243
+ defect["resolution"] = "skipped-unresolvable"
1244
+ element["annotationDefect"] = defect
1245
+ defects.append(defect)
1246
+ return defect
1247
+
1248
+ def crop_from_artboard(element, defect):
1249
+ rect = element.get("rect") or {}
1250
+ width = float(rect.get("width", 0))
1251
+ height = float(rect.get("height", 0))
1252
+ if not crop_dir or width < 2 or height < 2:
1253
+ return
1254
+ safe_name = re.sub(r'[\\/:*?"<>|]+', "_", element.get("name") or "slice")
1255
+ filename = "__artboard_crop__{}_{}_{}x{}.png".format(
1256
+ safe_name,
1257
+ round(float(rect.get("x", 0))),
1258
+ round(float(rect.get("y", 0))),
1259
+ round(width),
1260
+ round(height),
1261
+ )
1262
+ try:
1263
+ png_utils.crop_png(
1264
+ preview_path,
1265
+ (float(rect.get("x", 0)), float(rect.get("y", 0)), width, height),
1266
+ crop_dir / filename,
1267
+ scale,
1268
+ )
1269
+ defect["resolution"] = "artboard-crop"
1270
+ defect["cropSlice"] = filename
1271
+ except (OSError, ValueError):
1272
+ return
1273
+
1274
+ for element in spec.get("elements") or []:
1275
+ if element.get("type") != "slice":
1276
+ continue
1277
+ if collect_slice_files([element.get("name", "")], asset_index):
1278
+ continue
1279
+ append_defect(element, spec.get("size") or {})
1280
+ if element["annotationDefect"]["type"] == "missing-slice":
1281
+ crop_from_artboard(element, element["annotationDefect"])
1282
+ def has_text_overlay(elements, media_rect):
1283
+ """切图矩形内是否叠加文本:有叠加时画板裁切会把文字烘进资产,不可用作替换。"""
1284
+ mx = float(media_rect.get("x", 0))
1285
+ my = float(media_rect.get("y", 0))
1286
+ mw = float(media_rect.get("width", 0))
1287
+ mh = float(media_rect.get("height", 0))
1288
+ if mw <= 0 or mh <= 0:
1289
+ return False
1290
+ for other in elements:
1291
+ if other.get("type") != "text":
1292
+ continue
1293
+ other_rect = other.get("rect") or {}
1294
+ ox = float(other_rect.get("x", 0))
1295
+ oy = float(other_rect.get("y", 0))
1296
+ ow = float(other_rect.get("width", 0))
1297
+ oh = float(other_rect.get("height", 0))
1298
+ ix = max(0.0, min(mx + mw, ox + ow) - max(mx, ox))
1299
+ iy = max(0.0, min(my + mh, oy + oh) - max(my, oy))
1300
+ if ow * oh > 0 and ix * iy >= ow * oh * 0.5:
1301
+ return True
1302
+ return False
1303
+
1304
+ def is_vector_logo_group(elements, group_rect):
1305
+ """纯矢量 logo 组:矩形内全为 shape 矢量子件且无文本叠加(裁切不会烘字)。"""
1306
+ gx = float(group_rect.get("x", 0))
1307
+ gy = float(group_rect.get("y", 0))
1308
+ gw = float(group_rect.get("width", 0))
1309
+ gh = float(group_rect.get("height", 0))
1310
+ if gw <= 0 or gh <= 0:
1311
+ return False
1312
+ shapes = 0
1313
+ for other in elements:
1314
+ if other.get("type") != "shape":
1315
+ continue
1316
+ other_rect = other.get("rect") or {}
1317
+ ox = float(other_rect.get("x", 0))
1318
+ oy = float(other_rect.get("y", 0))
1319
+ ow = float(other_rect.get("width", 0))
1320
+ oh = float(other_rect.get("height", 0))
1321
+ if ox >= gx - 2 and oy >= gy - 2 \
1322
+ and ox + ow <= gx + gw + 2 and oy + oh <= gy + gh + 2:
1323
+ shapes += 1
1324
+ return shapes >= 1
1325
+
1326
+ for card in spec.get("cards") or []:
1327
+ card_rect = card.get("rect") or infer_card_rect(card.get("elements") or [])
1328
+ card_area = max(float(card_rect.get("width", 0)) * float(card_rect.get("height", 0)), 1)
1329
+ for element in card.get("elements") or []:
1330
+ if element.get("annotationDefect"):
1331
+ continue
1332
+ rect = element.get("rect") or {}
1333
+ width = float(rect.get("width", 0))
1334
+ height = float(rect.get("height", 0))
1335
+ is_compact_media = 12 <= width <= 220 and 12 <= height <= 220 \
1336
+ and width * height <= card_area * 0.2
1337
+ # 宽条媒体(按钮/横幅切图):宽可达整卡、高 ≤72pt。这类切图导出状态错
1338
+ # (如禁用态按钮)时整条色带失真,同样需要像素交叉验证;但仅当矩形内无
1339
+ # 文本叠加时才验证替换,避免裁切烘字后渲染再叠文本造成重复。
1340
+ is_strip_media = 24 <= height <= 72 and width >= 120 \
1341
+ and width <= float(card_rect.get("width", 0)) * 1.02 \
1342
+ and width * height <= card_area * 0.4 \
1343
+ and not has_text_overlay(card.get("elements") or [], rect)
1344
+ if element.get("type") == "slice":
1345
+ matched_slices = collect_slice_files([element.get("name", "")], asset_index)
1346
+ if matched_slices:
1347
+ asset_path = asset_root / matched_slices[0]
1348
+ width = float(rect.get("width", 0))
1349
+ height = float(rect.get("height", 0))
1350
+ if asset_root and (is_compact_media or is_strip_media):
1351
+ if mark_slice_visual_mismatch(
1352
+ element, asset_path, preview_path, rect, scale, defects,
1353
+ ):
1354
+ crop_from_artboard(element, element["annotationDefect"])
1355
+ continue
1356
+ defect = append_defect(element, card_rect)
1357
+ if defect["type"] == "missing-slice":
1358
+ crop_from_artboard(element, defect)
1359
+ elif element.get("type") == "symbol" and is_compact_media:
1360
+ append_defect(element, card_rect)
1361
+ crop_from_artboard(element, element["annotationDefect"])
1362
+ elif element.get("type") == "group" and is_compact_media \
1363
+ and not collect_slice_files([element.get("name", "")], asset_index) \
1364
+ and not has_text_overlay(card.get("elements") or [], rect) \
1365
+ and is_vector_logo_group(card.get("elements") or [], rect):
1366
+ # 纯矢量 logo 组(shapes 组、无切图导出):此前被静默丢弃(黄色行标整批缺失)。
1367
+ # 按 rect 画板裁切生成资产,生成器侧凭 cropSlice 提权进切片池消费。
1368
+ defect = append_defect(element, card_rect)
1369
+ if defect["type"] == "missing-slice":
1370
+ crop_from_artboard(element, defect)
1371
+ if defects:
1372
+ spec["annotationDefects"] = defects
1373
+ return defects
1374
+
1375
+
1376
+ def copy_slices(src_assets, dst_slices, matched_rel):
1377
+ dst_slices.mkdir(parents=True, exist_ok=True)
1378
+ for rel in matched_rel or []:
1379
+ s = src_assets / rel
1380
+ d = dst_slices / rel
1381
+ d.parent.mkdir(parents=True, exist_ok=True)
1382
+ shutil.copy2(s, d)
1383
+ return list(matched_rel or [])
1384
+ # ---------- 画板选择(与业务无关,纯通用关键字) ----------
1385
+ PRIORITY_KEYWORDS = [
1386
+ "未登录", "无数据", "无记录", "无交易", "空", "成功", "失败", "错误",
1387
+ "有数据", "有记录", "选择", "确认", "详情", "列表", "表单", "输入",
1388
+ "查询", "提醒", "展开", "修改", "更多", "子卡", "弹框", "弹窗",
1389
+ ]
1390
+
1391
+
1392
+ def select_canvases(all_names, args):
1393
+ n = len(all_names)
1394
+ if args.list:
1395
+ return None
1396
+ if args.only:
1397
+ kws = [k.strip() for k in args.only.split(",") if k.strip()]
1398
+ return [i for i, nm in enumerate(all_names) if any(k in nm for k in kws)]
1399
+ if args.indices:
1400
+ return [int(x) for x in args.indices.split(",") if x.strip()]
1401
+ if args.sample:
1402
+ k = min(args.sample, n)
1403
+ scored = []
1404
+ for i, nm in enumerate(all_names):
1405
+ score = sum(1 for kw in PRIORITY_KEYWORDS if kw in nm)
1406
+ scored.append((score, i))
1407
+ scored.sort(key=lambda t: (-t[0], t[1]))
1408
+ seen, picked = set(), []
1409
+ for sc, i in scored:
1410
+ fp = flow_prefix(all_names[i])
1411
+ if fp not in seen:
1412
+ seen.add(fp)
1413
+ picked.append(i)
1414
+ if len(picked) >= k:
1415
+ break
1416
+ for sc, i in scored:
1417
+ if i not in picked:
1418
+ picked.append(i)
1419
+ if len(picked) >= k:
1420
+ break
1421
+ return picked
1422
+ return list(range(n))
1423
+
1424
+
1425
+ # ---------- 交互分类(通用抽象:弹层/子卡/普通卡;画板名仅作辅助) ----------
1426
+ DIALOG_NAME_PATTERNS = [
1427
+ r"弹框", r"弹窗", r"对话框", r"浮层", r"确认框", r"提示框", r"警告框",
1428
+ r"二次确认", r"消息提示", r"轻提示", r"错误提示", r"toast",
1429
+ ]
1430
+ # 子卡名称模式:仅保留通用 UI 容器类型词(子卡/浮层卡/弹层卡),删除业务实体名
1431
+ # (选择账号/选择银行卡/银行卡选择 等是金融业务词,跨域会误判)。
1432
+ # 子卡判定改以视觉信号(底部抽屉面板 + scrim)为主,名称仅兜底。
1433
+ SUBCARD_NAME_PATTERNS = [
1434
+ r"子卡", r"子页面", r"子卡片", r"浮层卡", r"弹层卡", r"弹卡",
1435
+ r"弹出卡", r"弹窗卡", r"选择卡", r"卡列表",
1436
+ ]
1437
+ _DIALOG_RE = [re.compile(p, re.IGNORECASE) for p in DIALOG_NAME_PATTERNS]
1438
+ _SUBCARD_RE = [re.compile(p, re.IGNORECASE) for p in SUBCARD_NAME_PATTERNS]
1439
+
1440
+
1441
+ def detect_modal_layers(raw_layers, W=375, H=812):
1442
+ """视觉信号:模态遮罩(scrim) 与 底部抽屉面板(bottom-sheet)。
1443
+ 纯几何+视觉判定,无文本依赖:
1444
+ - scrim:近全屏(>=90%画板)且半透明(opacity<0.95)的深色层。
1445
+ - bottom-sheet:贴合屏幕底部(底边>=98%H)的通栏面板(宽>=95%W),顶缘大圆角(>8px)且底缘无圆角。
1446
+ W 默认 375(移动端设计稿规范宽度;若画板自带 width 则传入真实值)。
1447
+ """
1448
+ has_scrim = False
1449
+ has_bottom_sheet = False
1450
+
1451
+ def walk(ls):
1452
+ nonlocal has_scrim, has_bottom_sheet
1453
+ for L in ls:
1454
+ ty = L.get("type", "")
1455
+ r = L.get("rect") or {}
1456
+ w, h = r.get("width", 0), r.get("height", 0)
1457
+ x, y = r.get("x", 0), r.get("y", 0)
1458
+ op = L.get("opacity", 1)
1459
+ if w >= 0.9 * W and h >= 0.9 * H and isinstance(op, (int, float)) and op < 0.95:
1460
+ has_scrim = True
1461
+ # 底部抽屉:通栏 + 贴底 + 顶圆角底直角
1462
+ if ty in ("shape", "symbol", "group") and w >= 0.95 * W and h >= 0.2 * H \
1463
+ and (y + h) >= 0.98 * H:
1464
+ rad = L.get("radius") or []
1465
+ if rad:
1466
+ if len(rad) == 1:
1467
+ top_r, bottom_r = rad[0], rad[0]
1468
+ elif len(rad) >= 4:
1469
+ top_r, bottom_r = rad[0], rad[2]
1470
+ else:
1471
+ top_r, bottom_r = rad[0], (rad[1] if len(rad) >= 2 else 0)
1472
+ if isinstance(top_r, (int, float)) and top_r > 8 \
1473
+ and isinstance(bottom_r, (int, float)) and bottom_r == 0:
1474
+ has_bottom_sheet = True
1475
+ if L.get("layers"):
1476
+ walk(L["layers"])
1477
+ walk(raw_layers)
1478
+ return has_scrim, has_bottom_sheet
1479
+
1480
+
1481
+ def classify_artboard(name, spec, raw_layers, H=812):
1482
+ """返回 (artboardType, interaction)。视觉信号优先,名称仅兜底(保守)。
1483
+ 判定顺序:
1484
+ 1) 视觉:底部抽屉面板(bottom-sheet) → subcard;全屏遮罩(scrim) → overlay_dialog
1485
+ 2) 名称兜底:通用容器类型词(子卡/浮层/弹框/toast 等 UI 控件名,非业务实体名)
1486
+ """
1487
+ # 1) 视觉信号优先
1488
+ W = (spec.get("size") or {}).get("width", 375) if spec else 375
1489
+ focus_reason = (spec or {}).get("focusSkipReason", "")
1490
+ if focus_reason == "overlay_dialog_geometry":
1491
+ return "overlay_dialog", "alertDialog 系统弹框(视觉信号:居中嵌套对话框几何)"
1492
+ if focus_reason == "bottom_sheet_geometry":
1493
+ return "subcard", "alertCard 子卡(视觉信号:底部抽屉面板几何)"
1494
+ has_scrim, has_sheet = detect_modal_layers(raw_layers or [], W, H)
1495
+ if has_sheet:
1496
+ return "subcard", "alertCard 子卡(视觉信号:底部抽屉面板,由父卡 alertCard 打开)"
1497
+ if has_scrim:
1498
+ return "overlay_dialog", "alertDialog 系统弹框(视觉信号:全屏遮罩)"
1499
+ # 2) 名称兜底:通用 UI 容器类型词(非业务实体名)
1500
+ nm = normalize_name(name)
1501
+ if any(rx.search(nm) for rx in _SUBCARD_RE):
1502
+ return "subcard", "alertCard 子卡(名称兜底:通用容器类型词)"
1503
+ if any(rx.search(nm) for rx in _DIALOG_RE):
1504
+ return "overlay_dialog", "alertDialog 系统弹框(名称兜底:通用控件类型词)"
1505
+ return "card", ""
1506
+
1507
+
1508
+ # ---------- JSAPI 交互控件检测(通用控件类型,非业务词) ----------
1509
+ # 通用 UI 控件名(中英双语):密码/验证码/数字键盘/弹框确认/toast 均为跨业务
1510
+ # 通用控件名,不属于业务词。中文设计稿依赖中文关键词,英文稿依赖英文关键词。
1511
+ JSAPI_CONTROL_PATTERNS = [
1512
+ (re.compile(r"密码|password|pwd", re.I), "passwordDialog",
1513
+ "密码输入控件:调用 inputPasswordDialog JSAPI(数字键盘 + 加密输入)"),
1514
+ (re.compile(r"验证码|captcha|verification[_-]?code|sms[_-]?code", re.I), "captcha",
1515
+ "验证码输入控件:调用短信验证码 JSAPI"),
1516
+ (re.compile(r"数字键盘|keypad|number[_-]?keyboard", re.I), "numberKeyboard",
1517
+ "数字键盘控件:调用数字键盘 JSAPI"),
1518
+ (re.compile(r"^.*toast.*$|轻提示|消息提示|toast", re.I), "toast",
1519
+ "Toast 轻提示:调用 showToast JSAPI(短暂浮现后自动消失)"),
1520
+ (re.compile(r"二次确认|确认弹框|确认框|confirm[_-]?dialog|alert[_-]?dialog|confirm", re.I), "alertDialog",
1521
+ "二次确认弹框:调用 alertDialog JSAPI(title/message/okButton/cancelButton)"),
1522
+ ]
1523
+
1524
+
1525
+ def detect_jsapi_interactions(spec, raw_layers):
1526
+ """扫描卡片元素与画板,检测需调用 JSAPI 的外部交互控件。
1527
+ 返回 [{control, jsapi, note, source}] 列表。纯抽象控件类型判定,不含业务文案。
1528
+ """
1529
+ out = []
1530
+ seen = set()
1531
+ # 1) 画板名匹配(整体性控件,如「密码输入页」「验证码页」)
1532
+ canvas_name = spec.get("canvas", "")
1533
+ for pat, jsapi, note in JSAPI_CONTROL_PATTERNS:
1534
+ if pat.search(canvas_name) and jsapi not in seen:
1535
+ out.append({"control": jsapi, "jsapi": jsapi, "note": note, "source": "canvas"})
1536
+ seen.add(jsapi)
1537
+ # 2) 元素名匹配(局部控件,如「密码输入框」「数字键盘」组)
1538
+ def walk(ls):
1539
+ for L in ls:
1540
+ nm = L.get("name", "") or ""
1541
+ for pat, jsapi, note in JSAPI_CONTROL_PATTERNS:
1542
+ if pat.search(nm) and jsapi not in seen:
1543
+ out.append({"control": jsapi, "jsapi": jsapi, "note": note,
1544
+ "source": "element", "element": nm})
1545
+ seen.add(jsapi)
1546
+ if L.get("layers"):
1547
+ walk(L["layers"])
1548
+ walk(raw_layers)
1549
+ return out
1550
+
1551
+
1552
+ # ---------- 主流程 ----------
1553
+ def preview_image_for(src, base, ab, name):
1554
+ """解析某个画板**一一对应**的预览图绝对路径(门禁清单逐行配图用),找不到返回 None。
1555
+
1556
+ 优先级:① 已前处理产物 `<out>/<draft>/<画板名>/artboard.png`;
1557
+ ② 导出 `preview/<slug>.png`(MeaXure 的 slug 即预览文件名);
1558
+ ③ 导出 `imagePath` 字段指向的文件;④ `preview/<画板名>.png` 兜底。
1559
+ 缺图返回 None,由调用方显式标「预览图缺失」——禁止把图统一堆在清单末尾(序号会对不上)。
1560
+ """
1561
+ cands = [base / name / "artboard.png"]
1562
+ slug = (ab or {}).get("slug") or ""
1563
+ img = (ab or {}).get("imagePath") or ""
1564
+ if slug:
1565
+ cands.append(src / "preview" / f"{slug}.png")
1566
+ if img:
1567
+ cands.append(src / img)
1568
+ cands.append(src / "preview" / f"{name}.png")
1569
+ for c in cands:
1570
+ try:
1571
+ if c.is_file():
1572
+ return c
1573
+ except OSError:
1574
+ continue
1575
+ return None
1576
+
1577
+
1578
+ def print_board_line(i, nm, ab, src, base):
1579
+ """门禁/清单统一行格式:序号行 + 下一行该画板自己的预览图(一一对应;缺图标缺失)。"""
1580
+ sz = f'{ab.get("width")}x{ab.get("height")}' if ab else "?"
1581
+ print(f" {i + 1:>2}. {nm} ({sz})")
1582
+ img = preview_image_for(src, base, ab, nm)
1583
+ if img:
1584
+ print(f" 图: {img}")
1585
+ else:
1586
+ print(" 图: (预览图缺失:preview/<slug>.png 与 UI 产物 artboard.png 均不存在)")
1587
+
1588
+
1589
+ def main():
1590
+ ap = argparse.ArgumentParser(description="Sketch MeaXure 标注 → 卡片要素包(视觉驱动)")
1591
+ ap.add_argument("--src", default=str(DEFAULT_SRC))
1592
+ ap.add_argument("--out", default=str(DEFAULT_OUT))
1593
+ ap.add_argument("--list", action="store_true")
1594
+ ap.add_argument("--scope-all", action="store_true",
1595
+ help="显式授权跑全部画板:仅当用户回复「全部」后才可加(无人值守批量训练可直接加或设 SKETCH_SPEC_SCOPE_ALL=1)")
1596
+ ap.add_argument("--only", default=None)
1597
+ ap.add_argument("--indices", default=None)
1598
+ ap.add_argument("--sample", type=int, default=0)
1599
+ ap.add_argument("--build", action="store_true")
1600
+ ap.add_argument("--artboard-max-side", type=int, default=640,
1601
+ help="artboard.png 最长边降采样上限(默认 640;0=不降采样)")
1602
+ ap.add_argument("--draft", default=None,
1603
+ help="按稿分片(可选):画板落盘到 <out>/<draft>/<canvas>/,并写 <out>/<draft>/manifest.json;缺省平铺(兼容旧布局)")
1604
+ args = ap.parse_args()
1605
+
1606
+ src = Path(args.src).expanduser()
1607
+ out = Path(args.out).expanduser().resolve()
1608
+ # 按稿分片:指定 --draft 时,所有画板与 manifest 落在 <out>/<draft>/ 下(UI 多稿治理,item 19)
1609
+ base = out / args.draft if args.draft else out
1610
+ if not (src / "index.html").exists():
1611
+ print(f"[ERR] 未找到 {src / 'index.html'},请用 --src 指定导出目录", file=sys.stderr)
1612
+ sys.exit(1)
1613
+
1614
+ data = extract_data(src / "index.html")
1615
+ artboards = data.get("artboards", [])
1616
+ total = len(artboards)
1617
+ print(f"[INFO] 解析到 {total} 个画布(来源:{src.name})")
1618
+
1619
+ names = [ab.get("name", f"artboard-{i}") for i, ab in enumerate(artboards)]
1620
+ if args.list:
1621
+ # 展示统一 1 起(与多画板门禁清单同口径);--indices 仍为 0 起,用户序号 k → k-1
1622
+ # 每个画板行下面紧跟它自己那张预览图(一一对应;缺图显式标缺失),禁止把图堆在末尾
1623
+ for i, nm in enumerate(names):
1624
+ print_board_line(i, nm, artboards[i] if i < len(artboards) else None, src, base)
1625
+ print(" 全部. 全部")
1626
+ return
1627
+
1628
+ # ── 多画板选择门禁(rules.md §十三 / SKILL.md §2 步骤 2,2026-09-17 机械兜底)──
1629
+ # 未指定范围的生成运行必须先让用户从 1..n + 全部 里选;「全部」是用户的回答,不是 Agent 的默认值。
1630
+ # 这里做机械卡点:打印清单后 exit 4,拿到用户回答再用 --indices / --only / --scope-all 重跑。
1631
+ has_scope = bool(args.only or args.indices or args.sample)
1632
+ scope_all_env = os.environ.get("SKETCH_SPEC_SCOPE_ALL", "") in ("1", "true", "yes")
1633
+ if not has_scope and not args.scope_all and not scope_all_env:
1634
+ print(f"[GATE] 多画板选择门禁(rules.md §十三):本导出共 {total} 个画板,本次运行未指定范围"
1635
+ f"(--only/--indices/--sample/--scope-all 均未给)。")
1636
+ print("[GATE] 按铁律必须先把下面的清单(1..n + 全部)交给用户并**停止输出等回复**,"
1637
+ "不得自行跑全量(「生成产物 / 前处理」请求同样适用):")
1638
+ for i, nm in enumerate(names):
1639
+ print_board_line(i, nm, artboards[i] if i < len(artboards) else None, src, base)
1640
+ print(" 全部. 全部(整份导出全量跑 UI 产物;产卡时视为显式批量授权)")
1641
+ print("[GATE] 拿到回复后重跑:回复序号 k → --indices <k-1>(0 起,多个用逗号);"
1642
+ "回复「全部」→ --scope-all;无人值守批量训练可直接 --scope-all 或 SKETCH_SPEC_SCOPE_ALL=1。")
1643
+ sys.exit(4)
1644
+
1645
+ sel = select_canvases(names, args)
1646
+ if not sel:
1647
+ print("[WARN] 没有匹配到任何画布。", file=sys.stderr)
1648
+ sys.exit(0)
1649
+ print(f"[INFO] 本次生成 {len(sel)} 个画布:{', '.join(names[i] for i in sel)}")
1650
+
1651
+ assets_dir = src / "assets"
1652
+ asset_index = build_asset_index(assets_dir)
1653
+ out.mkdir(parents=True, exist_ok=True)
1654
+
1655
+ manifest = {
1656
+ "export": str(src),
1657
+ "draft": args.draft,
1658
+ "schemaVersion": "1.0",
1659
+ "generatedAt": datetime.now().isoformat(timespec="seconds"),
1660
+ "total": total,
1661
+ "canvases": [],
1662
+ }
1663
+
1664
+ # 部分运行(--only/--indices/--sample)只重解析选中画板;未选中画板的
1665
+ # status / trainingEligible / artboardType 必须从旧 manifest 继承,
1666
+ # 否则每次局部重跑都会把全量 eligibility 清空,下游数据集构建随之坍塌。
1667
+ previous_entries = {}
1668
+ previous_manifest = base / "manifest.json"
1669
+ partial_run = len(sel) < total
1670
+ if partial_run and previous_manifest.exists():
1671
+ try:
1672
+ saved = json.loads(previous_manifest.read_text(encoding="utf-8"))
1673
+ previous_entries = {
1674
+ item.get("name"): item
1675
+ for item in saved.get("canvases", [])
1676
+ if isinstance(item, dict) and item.get("name")
1677
+ }
1678
+ except (OSError, ValueError):
1679
+ previous_entries = {}
1680
+
1681
+ for i, ab in enumerate(artboards):
1682
+ name = names[i]
1683
+ status = "generated" if i in sel else "pending"
1684
+ entry = {
1685
+ "order": i + 1,
1686
+ "name": name,
1687
+ "slug": name,
1688
+ "status": status,
1689
+ "trainingEligible": False,
1690
+ }
1691
+ if i not in sel and name in previous_entries:
1692
+ for inherited in ("status", "trainingEligible", "artboardType", "preview"):
1693
+ if inherited in previous_entries[name]:
1694
+ entry[inherited] = previous_entries[name][inherited]
1695
+ if i in sel:
1696
+ cdir = base / name
1697
+ cdir.mkdir(parents=True, exist_ok=True)
1698
+
1699
+ # 整图截图(参考)
1700
+ preview_rel = unquote(ab.get("imagePath", ""))
1701
+ src_png = src / preview_rel
1702
+ if not src_png.exists():
1703
+ cand = list(src.glob(f"preview/*{name.split('_', 1)[-1]}*.png"))
1704
+ src_png = cand[0] if cand else None
1705
+ if src_png and src_png.exists():
1706
+ shutil.copy2(src_png, cdir / "artboard.png")
1707
+ entry["preview"] = f"{name}/artboard.png"
1708
+ # —— P1 优化(RC5):整屏 artboard.png 降采样,降低多模态 token 成本 ——
1709
+ # 卡片主体细节由聚焦 card-*.png 承载,artboard.png 仅作整体参考,
1710
+ # 最长边压到 --artboard-max-side(默认 640);0 表示不降采样。
1711
+ if args.artboard_max_side and args.artboard_max_side > 0:
1712
+ try:
1713
+ nw, nh = png_utils.downscale_png(
1714
+ str(cdir / "artboard.png"), str(cdir / "artboard.png"),
1715
+ args.artboard_max_side)
1716
+ print(f" [downscale] artboard.png → {nw}×{nh} (max_side={args.artboard_max_side})")
1717
+ except Exception as e: # noqa
1718
+ print(f" [WARN] artboard 降采样失败(保留原图):{e}", file=sys.stderr)
1719
+ else:
1720
+ print(f" [WARN] {name}: 找不到整图 {preview_rel}", file=sys.stderr)
1721
+
1722
+ # 视觉聚焦:卡片区域检测 + 裁切
1723
+ spec = focus_artboard(ab, src_png, cdir)
1724
+
1725
+ # 交互分类(视觉信号优先,名称兜底 — 详见 classify_artboard)
1726
+ at, interaction = classify_artboard(name, spec, ab.get("layers") or [], ab.get("height", 812))
1727
+ spec["artboardType"] = at
1728
+ spec["interaction"] = interaction
1729
+ # JSAPI 交互控件检测(密码/验证码/数字键盘/toast/二次确认)
1730
+ spec["jsapiInteractions"] = detect_jsapi_interactions(spec, ab.get("layers") or [])
1731
+ entry["artboardType"] = at
1732
+ entry["trainingEligible"] = at == "card"
1733
+
1734
+ # 切图:逐元素建立导出映射,未命中素材的标注降级为整图采样纯色。
1735
+ slice_names = spec.pop("slices", [])
1736
+ matched = set()
1737
+ for slice_name in slice_names:
1738
+ matched.update(collect_slice_files([slice_name], asset_index))
1739
+ slices_rel = copy_slices(assets_dir, cdir / "slices", sorted(matched))
1740
+ if entry.get("preview"):
1741
+ # 缺素材裁切必须用原分辨率 preview(src_png),不能用降采样后的 artboard.png
1742
+ # (artboard.png 最长边压到 640 仅为多模态 token 优化;用它裁切会得到 0.6x 模糊图)。
1743
+ mark_missing_slice_elements(
1744
+ spec, src_png, asset_index, assets_dir, cdir / "slices",
1745
+ )
1746
+ spec["slices"] = slices_rel
1747
+
1748
+ # 画板是否无卡(0 卡 → 标记跳过,保留整图供参考)
1749
+ # 仅当既无独立卡片、又元素极少(<3)才视为真正空画板;
1750
+ # 元素丰富但无卡的情况可能是底部弹窗/全屏覆盖层,保留 status 供下游决策。
1751
+ if not spec["cards"] and len(spec.get("elements", [])) < 3:
1752
+ entry["status"] = "skipped"
1753
+ spec["skippedReason"] = "视觉检测无独立卡片元素(纯对话/提示/无卡片画板)"
1754
+
1755
+ (cdir / "spec.json").write_text(
1756
+ json.dumps(spec, ensure_ascii=False, indent=2), encoding="utf-8")
1757
+
1758
+ n_cards = len(spec["cards"])
1759
+ n_shot = sum(1 for c in spec["cards"] if c.get("image"))
1760
+ print(f" [{'SKIP' if not n_cards else 'OK'}] {name}: {n_cards} 卡 / {n_shot} 聚焦图 / "
1761
+ f"{len(slices_rel)} 切图 / {len(spec['elements'])} 视觉元素 / [{at}]")
1762
+
1763
+ manifest["canvases"].append(entry)
1764
+
1765
+ (base / "manifest.json").write_text(
1766
+ json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
1767
+ print(f"[DONE] manifest.json 已写入 {base / 'manifest.json'}")
1768
+
1769
+ if args.build:
1770
+ try:
1771
+ import build_prompt as _bp
1772
+ print("[INFO] 一步式 --build:生成 prompt.md / spec.md ...")
1773
+ # 必须传 base(= out/<draft>):传 out 会去平铺 UI/ 找 manifest,
1774
+ # 结果是「本次画板没有 prompt.md,反而重写了别的稿的存量产物」。
1775
+ _bp.run(str(base), only=None, all_flag=False)
1776
+ except Exception as e: # noqa
1777
+ print(f"[WARN] --build 调用 build_prompt 失败:{e}", file=sys.stderr)
1778
+
1779
+
1780
+ if __name__ == "__main__":
1781
+ main()