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.
- package/README.md +46 -5
- package/dist/cli.js +51 -1
- package/dist/config.js +51 -16
- package/dist/core.js +2 -2
- package/dist/init.js +425 -0
- package/dist/preview-editor/assets/index-CHuGGIR3.js +276 -0
- package/dist/preview-editor/assets/{index-B4VZgNSK.css → index-CQScMhyg.css} +1 -1
- package/dist/preview-editor/index.html +2 -2
- package/dist/standalone.js +8 -0
- package/dist/vendored/mcp/client.mjs +537 -0
- package/dist/vendored/mcp/tools.mjs +268 -0
- package/dist/vendored/preview/assets/default-image.png +0 -0
- package/dist/{server-core → vendored/server/core}/ai-chat.js +82 -38
- package/dist/{server-core → vendored/server/core}/browser.js +1 -1
- package/dist/{server-core → vendored/server/core}/cardSave.js +8 -3
- package/dist/{server-core → vendored/server/core}/cardSync.js +6 -14
- package/dist/{server-core → vendored/server/core}/cardValidate.js +11 -8
- package/dist/{server-core → vendored/server/core}/index.js +5 -3
- package/dist/vendored/server/core/skillPaths.js +124 -0
- package/dist/{server-core → vendored/server/core}/sliceUpload.js +5 -8
- package/dist/vendored/skills/README.md +93 -0
- package/dist/vendored/skills/card-doctor/SKILL.md +52 -0
- package/dist/vendored/skills/card-skill-trainer/SKILL.md +312 -0
- package/dist/vendored/skills/card-skill-trainer/baselines/v3-r038n.json +43 -0
- package/dist/vendored/skills/card-skill-trainer/baselines/v3-r039n.json +47 -0
- package/dist/vendored/skills/card-skill-trainer/baselines/v3-r050n.json +48 -0
- package/dist/vendored/skills/card-skill-trainer/scripts/build_dataset.py +472 -0
- package/dist/vendored/skills/card-skill-trainer/scripts/build_sample.mjs +162 -0
- package/dist/vendored/skills/card-skill-trainer/scripts/diagnose_case.mjs +241 -0
- package/dist/vendored/skills/card-skill-trainer/scripts/lib/scoring.mjs +525 -0
- package/dist/vendored/skills/card-skill-trainer/scripts/round_diff.mjs +133 -0
- package/dist/vendored/skills/card-skill-trainer/scripts/run_round.mjs +1043 -0
- package/dist/vendored/skills/card-skill-trainer/scripts/score_round.mjs +195 -0
- package/dist/vendored/skills/card-skill-trainer/scripts/test_missing_slice.py +122 -0
- package/dist/vendored/skills/dsl-card-ai/SKILL.md +191 -0
- package/dist/vendored/skills/dsl-card-generator/SKILL.md +480 -0
- package/dist/vendored/skills/dsl-card-generator/config/render-contract.json +11 -0
- package/dist/vendored/skills/dsl-card-generator/references/amount_formatting.md +138 -0
- package/dist/vendored/skills/dsl-card-generator/references/card_index.json +1597 -0
- package/dist/vendored/skills/dsl-card-generator/references/card_templates.json +384 -0
- package/dist/vendored/skills/dsl-card-generator/references/card_to_summary.md +56 -0
- package/dist/vendored/skills/dsl-card-generator/references/component_rules.md +76 -0
- package/dist/vendored/skills/dsl-card-generator/references/component_specs.md +393 -0
- package/dist/vendored/skills/dsl-card-generator/references/dsl_structure.md +208 -0
- package/dist/vendored/skills/dsl-card-generator/references/event_patterns.md +287 -0
- package/dist/vendored/skills/dsl-card-generator/references/expression_engine.md +121 -0
- package/dist/vendored/skills/dsl-card-generator/references/field_name_dict.json +34 -0
- package/dist/vendored/skills/dsl-card-generator/references/field_naming.md +101 -0
- package/dist/vendored/skills/dsl-card-generator/references/generation_pitfalls.md +928 -0
- package/dist/vendored/skills/dsl-card-generator/references/handcrafted/README.md +69 -0
- package/dist/vendored/skills/dsl-card-generator/references/handcrafted/examples_index.json +2967 -0
- package/dist/vendored/skills/dsl-card-generator/references/handcrafted/patterns.json +2835 -0
- package/dist/vendored/skills/dsl-card-generator/references/handcrafted/recipes_v2.json +648 -0
- package/dist/vendored/skills/dsl-card-generator/references/handcrafted/recipes_v2_schema.md +295 -0
- package/dist/vendored/skills/dsl-card-generator/references/image_diff.md +48 -0
- package/dist/vendored/skills/dsl-card-generator/references/image_to_card.md +129 -0
- package/dist/vendored/skills/dsl-card-generator/references/ir-format.md +212 -0
- package/dist/vendored/skills/dsl-card-generator/references/message-center-pattern.md +149 -0
- package/dist/vendored/skills/dsl-card-generator/references/platform_jsapi.md +196 -0
- package/dist/vendored/skills/dsl-card-generator/references/previewer_contract.md +54 -0
- package/dist/vendored/skills/dsl-card-generator/references/quickref.md +197 -0
- package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/absolute-badge.json +84 -0
- package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/accordion-group-toggle.json +18 -0
- package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/alertCard-pair.json +12 -0
- package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/alertDialog-call.json +12 -0
- package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/canvas-countdown.json +15 -0
- package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/canvas-donut.json +15 -0
- package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/canvas-gauge-score.json +11 -0
- package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/canvas-radar.json +10 -0
- package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/canvas-title-head.json +13 -0
- package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/globalSet.json +12 -0
- package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/label-col-modes.json +21 -0
- package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/list-expand-collapse.json +17 -0
- package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/money-format.json +11 -0
- package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/opacity-switch.json +130 -0
- package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/progress-bar.json +25 -0
- package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/tab-billMode.json +15 -0
- package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/title-underline-overlay.json +21 -0
- package/dist/vendored/skills/dsl-card-generator/references/recipes.md +797 -0
- package/dist/vendored/skills/dsl-card-generator/references/rules.md +235 -0
- package/dist/vendored/skills/dsl-card-generator/references/slices_convention.md +177 -0
- package/dist/vendored/skills/dsl-card-generator/references/spec_to_schema_patterns.md +609 -0
- package/dist/vendored/skills/dsl-card-generator/references/ui_fidelity_rules.md +131 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/add_component.mjs +118 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/analyze_component_usage.mjs +153 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/apply_opacity_pattern.mjs +343 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/build_card_index.mjs +108 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/build_card_templates.mjs +190 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/check_fidelity.mjs +257 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/check_magic_values.mjs +79 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/crop_slices.mjs +98 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/describe_card.mjs +265 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/diff_bands.mjs +257 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/diff_card.mjs +316 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/expand_ir.mjs +873 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/extract_handcrafted.py +1199 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/extract_subtree.mjs +191 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/generate_cards.mjs +389 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/inventory_schemas.py +160 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/iterate.mjs +700 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/design_image.mjs +56 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/design_sample.mjs +328 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/layout-tokens.mjs +79 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/paths.mjs +103 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/scaffold.mjs +575 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/slicePath.mjs +81 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/slice_dedupe.mjs +147 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lint_autofix.mjs +363 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lint_card.mjs +1399 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/preflight.mjs +249 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/register_card_template.mjs +208 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/render_baseline.mjs +231 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/render_card.mjs +801 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/render_diff.mjs +239 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/resolve_card_template.mjs +73 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/route_input.mjs +447 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/scaffold_card.mjs +81 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/slices_normalize.mjs +316 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/spec_audit.mjs +462 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/spec_to_schema_v3.mjs +7735 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/spec_tree.mjs +355 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/validate_all_schemas.py +71 -0
- package/dist/vendored/skills/dsl-card-generator/vendor/preview/assets/default-image-wzZYQ86K.png +0 -0
- package/dist/vendored/skills/dsl-card-generator/vendor/preview/assets/standalone-C9k-07y7.css +1 -0
- package/dist/vendored/skills/dsl-card-generator/vendor/preview/assets/standalone-oFRM4f4V.js +55 -0
- package/dist/vendored/skills/dsl-card-generator/vendor/preview/assets/svg-symbols-B2exBrAr.svg +118 -0
- package/dist/vendored/skills/dsl-card-generator/vendor/preview/meta/component-meta.json +2714 -0
- package/dist/vendored/skills/dsl-card-generator/vendor/preview/meta/directive-meta.json +40 -0
- package/dist/vendored/skills/dsl-card-generator/vendor/preview/meta/style-meta.json +1086 -0
- package/dist/vendored/{standalone.html → skills/dsl-card-generator/vendor/preview/standalone.html} +2 -1
- package/dist/vendored/skills/finmall-card-sync/SKILL.md +262 -0
- package/dist/vendored/skills/finmall-session-navigator/SKILL.md +169 -0
- package/dist/vendored/skills/finmall-session-navigator/scripts/finmall_login.mjs +882 -0
- package/dist/vendored/skills/finmall-session-navigator/scripts/package-lock.json +59 -0
- package/dist/vendored/skills/finmall-session-navigator/scripts/package.json +10 -0
- package/dist/vendored/skills/finmall-session-navigator/scripts/session-manager.mjs +96 -0
- package/dist/vendored/skills/shared/README.md +55 -0
- package/dist/vendored/skills/shared/auth-model.md +79 -0
- package/dist/vendored/skills/sketch-spec-to-card/SKILL.md +329 -0
- package/dist/vendored/skills/sketch-spec-to-card/references/boundary.json +14 -0
- package/dist/vendored/skills/sketch-spec-to-card/references/card_refs.json +13 -0
- package/dist/vendored/skills/sketch-spec-to-card/references/spec-schema.md +103 -0
- package/dist/vendored/skills/sketch-spec-to-card/scripts/build_prompt.py +797 -0
- package/dist/vendored/skills/sketch-spec-to-card/scripts/card_refs.py +94 -0
- package/dist/vendored/skills/sketch-spec-to-card/scripts/cluster_states.py +706 -0
- package/dist/vendored/skills/sketch-spec-to-card/scripts/parse_meaxure.py +1781 -0
- package/dist/vendored/skills/sketch-spec-to-card/scripts/png_utils.py +318 -0
- package/dist/vendored/skills/sketch-spec-to-card/scripts/vision.py +1118 -0
- package/dist/vendored/templates/helloCard/data.json +10 -0
- package/dist/vendored/templates/helloCard/schema.json +238 -0
- package/dist/vendored/tools/link-skills.mjs +485 -0
- package/package.json +2 -1
- package/dist/preview-editor/assets/index-jl4YJzKv.js +0 -180
- package/dist/vendored/App.vue +0 -2858
- package/dist/vendored/ansi.js +0 -185
- package/dist/vendored/assets/apple-touch-icon.png +0 -0
- package/dist/vendored/assets/default-image.png +0 -0
- package/dist/vendored/assets/favicon-16.png +0 -0
- package/dist/vendored/assets/favicon-32.png +0 -0
- package/dist/vendored/assets/site-icon.png +0 -0
- package/dist/vendored/assets/svgIcons/icons.svg +0 -87
- package/dist/vendored/assets/svgIcons/index.js +0 -39
- package/dist/vendored/components/AiSettings.vue +0 -392
- package/dist/vendored/components/AlertCardInstance.vue +0 -58
- package/dist/vendored/components/AlertCardLayer.vue +0 -59
- package/dist/vendored/components/ApiCallPanel.vue +0 -127
- package/dist/vendored/components/ApiPayloadTree.vue +0 -201
- package/dist/vendored/components/AppToast.vue +0 -37
- package/dist/vendored/components/CardGallery.vue +0 -291
- package/dist/vendored/components/CardPreview.vue +0 -72
- package/dist/vendored/components/CardSelect.vue +0 -206
- package/dist/vendored/components/ChatComposer.vue +0 -413
- package/dist/vendored/components/ChatMessage.vue +0 -383
- package/dist/vendored/components/ChatPanel.vue +0 -937
- package/dist/vendored/components/ComponentPalette.vue +0 -301
- package/dist/vendored/components/ConfirmDialog.vue +0 -54
- package/dist/vendored/components/DatePickerOverlay.vue +0 -345
- package/dist/vendored/components/DebugPanel.vue +0 -1734
- package/dist/vendored/components/DeviceOverlay.vue +0 -70
- package/dist/vendored/components/Dropdown.vue +0 -193
- package/dist/vendored/components/EventEditor.vue +0 -273
- package/dist/vendored/components/HoverGuides.vue +0 -122
- package/dist/vendored/components/JsonTree.vue +0 -547
- package/dist/vendored/components/LogValue.vue +0 -52
- package/dist/vendored/components/MarkdownBody.vue +0 -64
- package/dist/vendored/components/NewCardDialog.vue +0 -132
- package/dist/vendored/components/NodeInspector.vue +0 -295
- package/dist/vendored/components/NumberStepper.vue +0 -125
- package/dist/vendored/components/PickerOverlay.vue +0 -131
- package/dist/vendored/components/PropControl.vue +0 -245
- package/dist/vendored/components/PropTree.vue +0 -128
- package/dist/vendored/components/RemotePreview.vue +0 -173
- package/dist/vendored/components/SelectionOverlay.vue +0 -105
- package/dist/vendored/components/StructureTree.vue +0 -1002
- package/dist/vendored/components/Switch.vue +0 -38
- package/dist/vendored/components/setters/SetBgPosition.vue +0 -64
- package/dist/vendored/components/setters/SetBgSize.vue +0 -90
- package/dist/vendored/components/setters/SetCode.vue +0 -198
- package/dist/vendored/components/setters/SetColor.vue +0 -244
- package/dist/vendored/components/setters/SetCustomSelect.vue +0 -58
- package/dist/vendored/components/setters/SetExpr.vue +0 -107
- package/dist/vendored/components/setters/SetGradient.vue +0 -125
- package/dist/vendored/components/setters/SetGrid9.vue +0 -68
- package/dist/vendored/components/setters/SetIconSelect.vue +0 -67
- package/dist/vendored/components/setters/SetIfForm.vue +0 -61
- package/dist/vendored/components/setters/SetImage.vue +0 -43
- package/dist/vendored/components/setters/SetList.vue +0 -139
- package/dist/vendored/components/setters/SetNumber.vue +0 -118
- package/dist/vendored/components/setters/SetNumberStyle.vue +0 -113
- package/dist/vendored/components/setters/SetPadding.vue +0 -77
- package/dist/vendored/components/setters/SetSegment.vue +0 -160
- package/dist/vendored/components/setters/SetSelect.vue +0 -51
- package/dist/vendored/components/setters/SetSwitch.vue +0 -49
- package/dist/vendored/components/setters/SetText.vue +0 -52
- package/dist/vendored/components/setters/props.js +0 -23
- package/dist/vendored/fidelity/baseline-messageOverview.json +0 -87
- package/dist/vendored/fidelity/calibration.json +0 -29
- package/dist/vendored/fidelity/fidelity-messageOverview-after.html +0 -114
- package/dist/vendored/fidelity/fidelity-messageOverview-after.json +0 -272
- package/dist/vendored/fidelity/fidelity-messageOverview.html +0 -185
- package/dist/vendored/fidelity/fidelity-messageOverview.json +0 -336
- package/dist/vendored/fidelity/fidelity-messageOverview_v2.html +0 -262
- package/dist/vendored/fidelity/fidelity-messageOverview_v2.json +0 -502
- package/dist/vendored/fidelity/fidelity-messageOverview_v3.html +0 -201
- package/dist/vendored/fidelity/fidelity-messageOverview_v3.json +0 -417
- package/dist/vendored/fidelity/fidelity-messageOverview_visual.html +0 -125
- package/dist/vendored/fidelity/fidelity-messageOverview_visual.json +0 -338
- package/dist/vendored/index.html +0 -16
- package/dist/vendored/main.js +0 -37
- package/dist/vendored/runtime/ai-api.js +0 -165
- package/dist/vendored/runtime/alert-card.js +0 -125
- package/dist/vendored/runtime/app-toast.js +0 -33
- package/dist/vendored/runtime/clipboard-node.js +0 -78
- package/dist/vendored/runtime/clipboard.js +0 -29
- package/dist/vendored/runtime/event-model.js +0 -300
- package/dist/vendored/runtime/hoverGuide.js +0 -31
- package/dist/vendored/runtime/jsapi-mock.js +0 -256
- package/dist/vendored/runtime/markdown.js +0 -140
- package/dist/vendored/runtime/node-model.js +0 -217
- package/dist/vendored/skills/.manifest.json +0 -1101
- package/dist/vendored/standalone-main.js +0 -36
- package/dist/vendored/styles/ai-chat.css +0 -1850
- package/dist/vendored/styles/app.css +0 -3648
- package/dist/vendored/styles/card-gallery.css +0 -285
- package/dist/vendored/styles/card-select.css +0 -288
- package/dist/vendored/styles/components.css +0 -383
- package/dist/vendored/styles/dropdown.css +0 -324
- package/dist/vendored/styles/json-tree.css +0 -308
- package/dist/vendored/styles/node-inspector.css +0 -800
- package/dist/vendored/styles/setters.css +0 -1050
- package/dist/vendored/styles/standalone.css +0 -27
- package/dist/vendored/styles/structure-tree.css +0 -460
- /package/dist/vendored/{api.js → preview/api.js} +0 -0
- /package/dist/vendored/{assets → preview/assets}/svg-symbols.svg +0 -0
- /package/dist/vendored/{components → preview/components}/AbsoluteLayout.vue +0 -0
- /package/dist/vendored/{components → preview/components}/Button.vue +0 -0
- /package/dist/vendored/{components → preview/components}/Canvas.vue +0 -0
- /package/dist/vendored/{components → preview/components}/CheckGroup.vue +0 -0
- /package/dist/vendored/{components → preview/components}/Column.vue +0 -0
- /package/dist/vendored/{components → preview/components}/CustomPlaceholder.vue +0 -0
- /package/dist/vendored/{components → preview/components}/Divider.vue +0 -0
- /package/dist/vendored/{components → preview/components}/GridLayout.vue +0 -0
- /package/dist/vendored/{components → preview/components}/Icon.vue +0 -0
- /package/dist/vendored/{components → preview/components}/Image.vue +0 -0
- /package/dist/vendored/{components → preview/components}/Input.vue +0 -0
- /package/dist/vendored/{components → preview/components}/ListLayout.vue +0 -0
- /package/dist/vendored/{components → preview/components}/Map.vue +0 -0
- /package/dist/vendored/{components → preview/components}/MultiColumn.vue +0 -0
- /package/dist/vendored/{components → preview/components}/MultiText.vue +0 -0
- /package/dist/vendored/{components → preview/components}/ProgressSlider.vue +0 -0
- /package/dist/vendored/{components → preview/components}/RadioButton.vue +0 -0
- /package/dist/vendored/{components → preview/components}/RenderTree.vue +0 -0
- /package/dist/vendored/{components → preview/components}/Root.vue +0 -0
- /package/dist/vendored/{components → preview/components}/Row.vue +0 -0
- /package/dist/vendored/{components → preview/components}/SvgCode.vue +0 -0
- /package/dist/vendored/{components → preview/components}/SvgSymbols.vue +0 -0
- /package/dist/vendored/{components → preview/components}/Text.vue +0 -0
- /package/dist/vendored/{components → preview/components}/Video.vue +0 -0
- /package/dist/vendored/{components → preview/components}/ViewContainer.vue +0 -0
- /package/dist/vendored/{components → preview/components}/base.js +0 -0
- /package/dist/vendored/{components → preview/components}/icons.js +0 -0
- /package/dist/vendored/{components → preview/components}/index.js +0 -0
- /package/dist/vendored/{meta → preview/meta}/component-meta.json +0 -0
- /package/dist/vendored/{meta → preview/meta}/directive-meta.json +0 -0
- /package/dist/vendored/{meta → preview/meta}/style-meta.json +0 -0
- /package/dist/vendored/{runtime → preview/runtime}/StyleParser.js +0 -0
- /package/dist/vendored/{runtime → preview/runtime}/adapter.js +0 -0
- /package/dist/vendored/{runtime → preview/runtime}/assets.js +0 -0
- /package/dist/vendored/{runtime → preview/runtime}/canvas-context.js +0 -0
- /package/dist/vendored/{runtime → preview/runtime}/card-runtime.js +0 -0
- /package/dist/vendored/{runtime → preview/runtime}/evaluate.js +0 -0
- /package/dist/vendored/{runtime → preview/runtime}/property.js +0 -0
- /package/dist/vendored/{runtime → preview/runtime}/selectionOverlay.js +0 -0
- /package/dist/vendored/{runtime → preview/runtime}/toPreview.js +0 -0
- /package/dist/{server-core → vendored/server/core}/assetProxy.js +0 -0
- /package/dist/{server-core → vendored/server/core}/bbox.js +0 -0
- /package/dist/{server-core → vendored/server/core}/buildDataFromSchema.js +0 -0
- /package/dist/{server-core → vendored/server/core}/cardFiles.js +0 -0
- /package/dist/{server-core → vendored/server/core}/cardHistory.js +0 -0
- /package/dist/{server-core → vendored/server/core}/cardImage.js +0 -0
- /package/dist/{server-core → vendored/server/core}/cardImportMeta.js +0 -0
- /package/dist/{server-core → vendored/server/core}/cardListCache.js +0 -0
- /package/dist/{server-core → vendored/server/core}/cardOpenFolder.js +0 -0
- /package/dist/{server-core → vendored/server/core}/cardSidecar.js +0 -0
- /package/dist/{server-core → vendored/server/core}/codeBridge.js +0 -0
- /package/dist/{server-core → vendored/server/core}/logStream.js +0 -0
- /package/dist/{server-core → vendored/server/core}/logger.js +0 -0
- /package/dist/{server-core → vendored/server/core}/nodeModels.js +0 -0
- /package/dist/{server-core → vendored/server/core}/renderStatus.js +0 -0
- /package/dist/{server-core → vendored/server/core}/screenshot.js +0 -0
- /package/dist/{server-core → vendored/server/core}/sliceStatic.js +0 -0
- /package/dist/vendored/{meta → skills/dsl-card-generator/vendor/preview/meta}/action-meta.json +0 -0
- /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()
|