mta-mcp 3.13.0 → 3.14.2
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/dist/index.js +9 -5
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/standards/frameworks/flutter.md +135 -0
- package/standards/troubleshooting-cases/flutter/input-focus-background-color.md +79 -0
- package/standards/troubleshooting-cases/flutter/sketch-fontweight-mapping.md +105 -0
- package/standards/troubleshooting-cases/flutter/sketch-svg-bitmap-export.md +112 -0
- package/ui/sketch/mta-measure.sketchplugin/Contents/Sketch/measure.js +53 -5
- package/ui/sketch/sketch-tools.js +23 -0
package/package.json
CHANGED
|
@@ -1072,6 +1072,43 @@ height = 文本高度 ÷ 字号 = 20 ÷ 14 = 1.43
|
|
|
1072
1072
|
4. 设置 `Container.alignment: Alignment.center`
|
|
1073
1073
|
5. 设置 `contentPadding: EdgeInsets.zero` + `isDense: true`
|
|
1074
1074
|
|
|
1075
|
+
### TextField Focus 背景色全局修复
|
|
1076
|
+
|
|
1077
|
+
> ⚠️ **TextField 聚焦时出现蓝色半透明背景,需要在 ThemeData 两处同时消除**
|
|
1078
|
+
|
|
1079
|
+
Flutter 默认 `focusColor = Color(0x1F2196F3)`(蓝色 12% 透明度),在 TextField focus 时作为 State Layer 叠加在背景上,视觉效果很突兀。
|
|
1080
|
+
|
|
1081
|
+
```dart
|
|
1082
|
+
// ✅ 必须在 ThemeData 和 InputDecorationTheme 两处同时设置
|
|
1083
|
+
ThemeData(
|
|
1084
|
+
focusColor: Colors.transparent, // 消除 focus state layer
|
|
1085
|
+
hoverColor: Colors.transparent, // 消除 hover state layer
|
|
1086
|
+
splashColor: Colors.transparent, // 消除水波纹
|
|
1087
|
+
highlightColor: Colors.transparent, // 消除高亮
|
|
1088
|
+
inputDecorationTheme: InputDecorationTheme(
|
|
1089
|
+
filled: true,
|
|
1090
|
+
fillColor: Colors.transparent,
|
|
1091
|
+
focusColor: Colors.transparent, // 双重保护
|
|
1092
|
+
hoverColor: Colors.transparent,
|
|
1093
|
+
border: InputBorder.none,
|
|
1094
|
+
enabledBorder: InputBorder.none,
|
|
1095
|
+
focusedBorder: InputBorder.none,
|
|
1096
|
+
),
|
|
1097
|
+
)
|
|
1098
|
+
|
|
1099
|
+
// ❌ 错误 - 只改 InputDecorationTheme 不够,ThemeData 级别仍然生效
|
|
1100
|
+
inputDecorationTheme: InputDecorationTheme(
|
|
1101
|
+
focusColor: Colors.transparent, // 单独设置无效
|
|
1102
|
+
),
|
|
1103
|
+
```
|
|
1104
|
+
|
|
1105
|
+
| 属性 | 控制范围 |
|
|
1106
|
+
|------|----------|
|
|
1107
|
+
| `ThemeData.focusColor` | 全局 focus 状态叠加色 |
|
|
1108
|
+
| `ThemeData.hoverColor` | 全局 hover 状态叠加色 |
|
|
1109
|
+
| `InputDecorationTheme.focusColor` | TextField 专属 focus 色 |
|
|
1110
|
+
| `InputDecorationTheme.fillColor` | TextField 填充色 |
|
|
1111
|
+
|
|
1075
1112
|
---
|
|
1076
1113
|
|
|
1077
1114
|
## 🎨 Sketch/Figma 设计稿还原规范
|
|
@@ -1199,6 +1236,43 @@ SvgPicture.asset(
|
|
|
1199
1236
|
)
|
|
1200
1237
|
```
|
|
1201
1238
|
|
|
1239
|
+
#### Bitmap 图层处理规范
|
|
1240
|
+
|
|
1241
|
+
Sketch 将 Bitmap(位图)图层"导出为 SVG"时,**并不生成矢量路径**,而是嵌入 base64 PNG 数据:
|
|
1242
|
+
|
|
1243
|
+
```xml
|
|
1244
|
+
<!-- Bitmap 导出 SVG 的实际内容 - 只有 base64 图片,无矢量路径 -->
|
|
1245
|
+
<svg viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg">
|
|
1246
|
+
<image xlink:href="data:image/png;base64,iVBORw0KGgo..." width="12" height="12"/>
|
|
1247
|
+
</svg>
|
|
1248
|
+
```
|
|
1249
|
+
|
|
1250
|
+
**正确处理方式:识别 Bitmap 图层后,改为 PNG 导出,Flutter 使用 `Image.asset`**:
|
|
1251
|
+
|
|
1252
|
+
```javascript
|
|
1253
|
+
// 判断图层是否为 Bitmap
|
|
1254
|
+
const isBitmap = layer.type === 'Image';
|
|
1255
|
+
|
|
1256
|
+
// Bitmap 图层导出 PNG(@3x 高清)
|
|
1257
|
+
if (isBitmap) {
|
|
1258
|
+
sketch.export(layer, { formats: 'png', scales: '3', output: '/path/to/assets/icons/' });
|
|
1259
|
+
}
|
|
1260
|
+
```
|
|
1261
|
+
|
|
1262
|
+
```dart
|
|
1263
|
+
// ✅ 正确 - Bitmap 图层用 Image.asset,不用 SvgPicture.asset
|
|
1264
|
+
Image.asset(
|
|
1265
|
+
'assets/icons/ic_example.png',
|
|
1266
|
+
width: 12,
|
|
1267
|
+
height: 12,
|
|
1268
|
+
color: AppColors.textDarkSecondary, // 按需染色
|
|
1269
|
+
colorBlendMode: BlendMode.srcIn,
|
|
1270
|
+
)
|
|
1271
|
+
|
|
1272
|
+
// ❌ 错误 - 对 Bitmap SVG 用 SvgPicture 会显示模糊的 base64 位图
|
|
1273
|
+
SvgPicture.asset('assets/icons/ic_example.svg') // 实际是嵌套 PNG,非矢量
|
|
1274
|
+
```
|
|
1275
|
+
|
|
1202
1276
|
#### 颜色透明度转换
|
|
1203
1277
|
|
|
1204
1278
|
设计稿颜色格式:`#RRGGBBAA`(最后两位是透明度)
|
|
@@ -1217,6 +1291,57 @@ Flutter: Color(0xB31C2B45) 或 SVG fill-opacity="0.7"
|
|
|
1217
1291
|
| 50% | 80 | #00000080 |
|
|
1218
1292
|
| 15% | 26 | #1C2B4526 |
|
|
1219
1293
|
|
|
1294
|
+
### 字重(FontWeight)还原规范
|
|
1295
|
+
|
|
1296
|
+
> ⚠️ **Sketch `style.fontWeight` 返回的是内部索引值,≠ Flutter FontWeight 数值**(高频错误)
|
|
1297
|
+
|
|
1298
|
+
#### 错误根因
|
|
1299
|
+
|
|
1300
|
+
Sketch JS API 的 `layer.style.fontWeight` 返回 5、6、8、9 等内部索引,与 CSS/Flutter 的字重体系(400、500、600、700)**没有直接倍数关系**,不能 `×100` 使用。
|
|
1301
|
+
|
|
1302
|
+
| Sketch `fontWeight` 值 | 实际字体面 | Flutter 正确值 | 常见错误推断 |
|
|
1303
|
+
|---|---|---|---|
|
|
1304
|
+
| 5 | PingFangSC-Regular | `FontWeight.w400` | ~~w500~~(直接 ×100) |
|
|
1305
|
+
| 6 | PingFangSC-Medium | `FontWeight.w500` | ~~w600~~ |
|
|
1306
|
+
| 8 | PingFangSC-Semibold | `FontWeight.w600` | ~~w800~~(最常见错误!)|
|
|
1307
|
+
| 9 | PingFangSC-Bold | `FontWeight.w700` | ~~w900~~ |
|
|
1308
|
+
|
|
1309
|
+
#### 正确获取方式(必须用 native API)
|
|
1310
|
+
|
|
1311
|
+
```javascript
|
|
1312
|
+
// ❌ 错误 - fontWeight 数字不等于 Flutter FontWeight 值
|
|
1313
|
+
const weight = layer.style.fontWeight; // 返回 8,不代表 w800
|
|
1314
|
+
|
|
1315
|
+
// ✅ 正确 - 获取字体面完整名称,再按规则映射
|
|
1316
|
+
const fontName = String(layer.sketchObject.font().fontName());
|
|
1317
|
+
// "PingFangSC-Regular" → FontWeight.w400
|
|
1318
|
+
// "PingFangSC-Medium" → FontWeight.w500
|
|
1319
|
+
// "PingFangSC-Semibold" → FontWeight.w600
|
|
1320
|
+
// "PingFangSC-Bold" → FontWeight.w700
|
|
1321
|
+
|
|
1322
|
+
// 批量提取 Text 图层字重的完整脚本
|
|
1323
|
+
function getLayerFontWeight(layer) {
|
|
1324
|
+
const fontName = String(layer.sketchObject.font().fontName());
|
|
1325
|
+
const map = {
|
|
1326
|
+
'Regular': 'FontWeight.w400',
|
|
1327
|
+
'Medium': 'FontWeight.w500',
|
|
1328
|
+
'Semibold': 'FontWeight.w600',
|
|
1329
|
+
'Bold': 'FontWeight.w700',
|
|
1330
|
+
};
|
|
1331
|
+
const variant = Object.keys(map).find(k => fontName.includes(k)) || 'Regular';
|
|
1332
|
+
return { fontName, fontWeight: map[variant] };
|
|
1333
|
+
}
|
|
1334
|
+
```
|
|
1335
|
+
|
|
1336
|
+
#### 字重与 UI 元素对照(PingFangSC 典型规律)
|
|
1337
|
+
|
|
1338
|
+
| UI 元素 | 常用字体面 | Flutter FontWeight |
|
|
1339
|
+
|--------|--------|-------------------|
|
|
1340
|
+
| 说明文字、普通标签 | PingFangSC-Regular | `w400` |
|
|
1341
|
+
| 次标题、中等强调 | PingFangSC-Medium | `w500` |
|
|
1342
|
+
| 重要数值、价格金额 | PingFangSC-Semibold | `w600` |
|
|
1343
|
+
| 页面主标题 | PingFangSC-Bold | `w700` |
|
|
1344
|
+
|
|
1220
1345
|
### 问题速查表
|
|
1221
1346
|
|
|
1222
1347
|
> ⚠️ **修改代码前,先检查是否属于已知问题类型**
|
|
@@ -1231,6 +1356,10 @@ Flutter: Color(0xB31C2B45) 或 SVG fill-opacity="0.7"
|
|
|
1231
1356
|
| Row 内 Gap 间距无效 | #6 Gap 方向错误 | `SizedBox(width:)` 或 `Gap.h()` |
|
|
1232
1357
|
| **SVG 颜色比设计稿浅** | #7 ColorFilter 覆盖 | **移除 ColorFilter,保留 SVG 原有样式** |
|
|
1233
1358
|
| **SVG 图标未居中** | #8 viewBox 不匹配 | **SVG viewBox 与使用尺寸一致** |
|
|
1359
|
+
| **字重比设计稿更粗/更细** | #9 fontWeight 数字误读 | **用 `font().fontName()` 获取字体面名称映射** |
|
|
1360
|
+
| **SVG 图标模糊/非矢量** | #10 Bitmap 图层误用 | **位图图层改用 PNG 导出 + `Image.asset`** |
|
|
1361
|
+
| **TextField 聚焦出现蓝色背景** | #11 Focus 颜色未清除 | **ThemeData + InputDecorationTheme 两处设 transparent** |
|
|
1362
|
+
| **Obx 不触发 RxSet 刷新** | #12 GetX 引用未订阅 | **Obx 内用 `Set.from(rxSet)` 创建新集合触发订阅** |
|
|
1234
1363
|
|
|
1235
1364
|
### 还原检查清单
|
|
1236
1365
|
|
|
@@ -1247,6 +1376,8 @@ Flutter: Color(0xB31C2B45) 或 SVG fill-opacity="0.7"
|
|
|
1247
1376
|
| **边框** | color, thickness, position | `border: Border.all(...)` |
|
|
1248
1377
|
| **不透明度** | opacity (颜色末尾两位) | 颜色 alpha 或 `Opacity` widget |
|
|
1249
1378
|
| **图标** | SVG path, fill color, opacity | `SvgPicture.asset` |
|
|
1379
|
+
| **字重** | fontFace 名称(非 fontWeight 数字) | `FontWeight.w400/w500/w600/w700` |
|
|
1380
|
+
| **图标类型** | 矢量(Shape) vs 位图(Image) | Shape→SVG;Image→PNG+`Image.asset` |
|
|
1250
1381
|
|
|
1251
1382
|
### 禁止事项
|
|
1252
1383
|
|
|
@@ -1257,6 +1388,10 @@ Flutter: Color(0xB31C2B45) 或 SVG fill-opacity="0.7"
|
|
|
1257
1388
|
5. ❌ **禁止遗漏阴影参数** - 必须读取全部 5 个参数
|
|
1258
1389
|
6. ❌ **禁止忽略透明度** - 颜色 `#RRGGBBAA` 最后两位是透明度
|
|
1259
1390
|
7. ❌ **禁止 ColorFilter 覆盖 SVG** - 除非明确需要改变颜色
|
|
1391
|
+
8. ❌ **禁止用 Sketch `fontWeight` 数字推断 Flutter FontWeight** - 必须通过字体面名称(`font().fontName()`)映射
|
|
1392
|
+
9. ❌ **禁止对 Bitmap 图层使用 SvgPicture.asset** - 必须导出 PNG,用 `Image.asset`
|
|
1393
|
+
10. ❌ **禁止只在 InputDecorationTheme 修复 focus 背景色** - ThemeData 级别的 focusColor 也必须设置
|
|
1394
|
+
11. ❌ **禁止在 Obx 外传入 RxSet 引用** - 必须在 Obx lambda 内用 `Set.from()` 触发响应式订阅
|
|
1260
1395
|
|
|
1261
1396
|
---
|
|
1262
1397
|
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# TextField 聚焦时出现蓝色背景(Focus State Layer)
|
|
2
|
+
|
|
3
|
+
**问题标签**: `TextField`, `InputDecoration`, `ThemeData`, `focusColor`, `focus`, `背景色`
|
|
4
|
+
**问题类型**: `UI 样式异常`
|
|
5
|
+
**严重程度**: 中
|
|
6
|
+
**节省时间**: 多轮尝试 → 1 步修复
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## 问题描述
|
|
11
|
+
|
|
12
|
+
TextField 点击聚焦后,输入框背景出现蓝色半透明叠加色(State Layer),与设计稿完全不符,视觉效果很突兀。
|
|
13
|
+
|
|
14
|
+
## 问题根因
|
|
15
|
+
|
|
16
|
+
Flutter Material Design 默认会在 `ThemeData.focusColor` 设置带透明度的蓝色:
|
|
17
|
+
|
|
18
|
+
```dart
|
|
19
|
+
// Flutter 框架默认值
|
|
20
|
+
ThemeData(
|
|
21
|
+
focusColor: Color(0x1F2196F3), // 蓝色 12% 透明度 → 即 12% 不透明蓝色叠加层
|
|
22
|
+
)
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
当 TextField 聚焦时,这个颜色作为 **State Layer** 叠加在 `fillColor` 上方,产生明显的蓝色背景。
|
|
26
|
+
|
|
27
|
+
**陷阱**:只在 `InputDecorationTheme` 中设置 `focusColor: Colors.transparent` 并不够——`ThemeData` 级别的 `focusColor` 仍然生效,在某些情况下依然会显示。
|
|
28
|
+
|
|
29
|
+
## 错误尝试(导致反复)
|
|
30
|
+
|
|
31
|
+
| 尝试 | 结果 |
|
|
32
|
+
|------|------|
|
|
33
|
+
| 只设置 `InputDecorationTheme.focusColor: transparent` | 部分场景仍有蓝色 |
|
|
34
|
+
| 设置 `TextField.decoration.fillColor: transparent` | 填充色改了,但 focus 叠加色仍在 |
|
|
35
|
+
| 单独设置某个 TextField 的样式 | 新增 TextField 又出现问题,不是全局修复 |
|
|
36
|
+
|
|
37
|
+
## 正确方案
|
|
38
|
+
|
|
39
|
+
必须在 `ThemeData` 和 `InputDecorationTheme` **两处同时**设置:
|
|
40
|
+
|
|
41
|
+
```dart
|
|
42
|
+
ThemeData(
|
|
43
|
+
// 全局消除 focus/hover/ripple State Layer
|
|
44
|
+
focusColor: Colors.transparent,
|
|
45
|
+
hoverColor: Colors.transparent,
|
|
46
|
+
splashColor: Colors.transparent,
|
|
47
|
+
highlightColor: Colors.transparent,
|
|
48
|
+
|
|
49
|
+
inputDecorationTheme: InputDecorationTheme(
|
|
50
|
+
filled: true,
|
|
51
|
+
fillColor: Colors.transparent,
|
|
52
|
+
focusColor: Colors.transparent, // 针对 TextField 的双重保护
|
|
53
|
+
hoverColor: Colors.transparent,
|
|
54
|
+
// 消除聚焦时的边框
|
|
55
|
+
border: InputBorder.none,
|
|
56
|
+
enabledBorder: InputBorder.none,
|
|
57
|
+
focusedBorder: InputBorder.none,
|
|
58
|
+
errorBorder: InputBorder.none,
|
|
59
|
+
focusedErrorBorder: InputBorder.none,
|
|
60
|
+
),
|
|
61
|
+
)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## 各属性作用说明
|
|
65
|
+
|
|
66
|
+
| 属性 | 控制范围 | 默认值 |
|
|
67
|
+
|------|----------|--------|
|
|
68
|
+
| `ThemeData.focusColor` | 全局所有 Widget 的 focus 状态叠加色 | `Color(0x1F2196F3)` |
|
|
69
|
+
| `ThemeData.hoverColor` | 全局所有 Widget 的 hover 状态叠加色 | `Color(0x0A000000)` |
|
|
70
|
+
| `ThemeData.splashColor` | InkWell 水波纹颜色 | 蓝色变体 |
|
|
71
|
+
| `ThemeData.highlightColor` | 长按高亮颜色 | 半透明 |
|
|
72
|
+
| `InputDecorationTheme.focusColor` | TextField 专属 focus 层(二次覆盖) | 跟随 ThemeData |
|
|
73
|
+
| `InputDecorationTheme.fillColor` | TextField 填充背景色 | 跟随主题 |
|
|
74
|
+
|
|
75
|
+
## 关键原则
|
|
76
|
+
|
|
77
|
+
1. **ThemeData 和 InputDecorationTheme 都必须设置**,只改一处不够
|
|
78
|
+
2. 如果项目有多套主题(亮色/暗色),**每套主题都需要单独设置**
|
|
79
|
+
3. 在明确需要 focus 效果的按钮上(如 `ElevatedButton`),可单独通过 `ButtonStyle` 恢复,不影响全局配置
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# Sketch fontWeight 数字 ≠ Flutter FontWeight 值
|
|
2
|
+
|
|
3
|
+
**问题标签**: `sketch`, `fontWeight`, `字重`, `PingFangSC`, `字体`, `设计稿还原`
|
|
4
|
+
**问题类型**: `UI 还原错误`
|
|
5
|
+
**严重程度**: 高
|
|
6
|
+
**节省时间**: 多轮调试 → 1 次确认
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## 问题描述
|
|
11
|
+
|
|
12
|
+
从 Sketch 设计稿读取 `layer.style.fontWeight` 后,按 `值 × 100` 推算 Flutter FontWeight,导致字重比设计稿偏粗或偏细:
|
|
13
|
+
|
|
14
|
+
- 设计师说"这是常规字重",但代码里写了 `FontWeight.w500`(实际应是 w400)
|
|
15
|
+
- 价格数值看起来异常加粗,代码里用了 `FontWeight.w800`(实际应是 w600)
|
|
16
|
+
|
|
17
|
+
## 问题根因
|
|
18
|
+
|
|
19
|
+
Sketch JS API 的 `style.fontWeight` 返回的是**字体变体的内部枚举索引**(5、6、8、9...),与 CSS/Flutter 标准字重体系(100 ~ 900)**无数学对应关系**,不能直接 `×100` 使用。
|
|
20
|
+
|
|
21
|
+
| Sketch `style.fontWeight` | 实际字体面 | Flutter 正确值 | 错误推断 |
|
|
22
|
+
|---|---|---|---|
|
|
23
|
+
| 5 | PingFangSC-Regular | `FontWeight.w400` | ~~w500~~ |
|
|
24
|
+
| 6 | PingFangSC-Medium | `FontWeight.w500` | ~~w600~~ |
|
|
25
|
+
| 8 | PingFangSC-Semibold | `FontWeight.w600` | **~~w800~~(最常见!)** |
|
|
26
|
+
| 9 | PingFangSC-Bold | `FontWeight.w700` | ~~w900~~ |
|
|
27
|
+
|
|
28
|
+
## 错误尝试(导致反复)
|
|
29
|
+
|
|
30
|
+
| 尝试 | 结果 |
|
|
31
|
+
|------|------|
|
|
32
|
+
| 直接 `fontWeight × 100` | 所有字重偏粗(w500→w600,w600→w800) |
|
|
33
|
+
| 猜测"5≈medium≈w500" | 有时正确,有时偏差一级 |
|
|
34
|
+
| 看字体名前半段"PingFangSC" | 没有变体信息,无法区分 Regular/Semibold |
|
|
35
|
+
|
|
36
|
+
## 正确方案
|
|
37
|
+
|
|
38
|
+
### 获取字体面完整名称(必须用 Sketch native API)
|
|
39
|
+
|
|
40
|
+
```javascript
|
|
41
|
+
// ❌ 错误 - 返回内部索引,含义不明确
|
|
42
|
+
const weight = layer.style.fontWeight; // 返回 8,≠ w800
|
|
43
|
+
|
|
44
|
+
// ✅ 正确 - 获取字体面完整名称
|
|
45
|
+
const fontName = String(layer.sketchObject.font().fontName());
|
|
46
|
+
// 返回 "PingFangSC-Semibold" → 映射到 FontWeight.w600
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### 标准映射表
|
|
50
|
+
|
|
51
|
+
```javascript
|
|
52
|
+
// PingFangSC 字体映射(中文场景最常见)
|
|
53
|
+
const PINGFANG_WEIGHT_MAP = {
|
|
54
|
+
'Ultralight': 'FontWeight.w100',
|
|
55
|
+
'Thin': 'FontWeight.w200',
|
|
56
|
+
'Light': 'FontWeight.w300',
|
|
57
|
+
'Regular': 'FontWeight.w400',
|
|
58
|
+
'Medium': 'FontWeight.w500',
|
|
59
|
+
'Semibold': 'FontWeight.w600',
|
|
60
|
+
'Bold': 'FontWeight.w700',
|
|
61
|
+
'Heavy': 'FontWeight.w800',
|
|
62
|
+
'Black': 'FontWeight.w900',
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
function getFlutterFontWeight(layer) {
|
|
66
|
+
const fontName = String(layer.sketchObject.font().fontName());
|
|
67
|
+
const variant = Object.keys(PINGFANG_WEIGHT_MAP).find(k => fontName.includes(k));
|
|
68
|
+
return PINGFANG_WEIGHT_MAP[variant || 'Regular'];
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### 批量提取文本样式脚本
|
|
73
|
+
|
|
74
|
+
```javascript
|
|
75
|
+
const sketch = require('sketch');
|
|
76
|
+
const page = sketch.getSelectedDocument().selectedPage;
|
|
77
|
+
|
|
78
|
+
function extractTextStyles(containerName) {
|
|
79
|
+
const container = sketch.find(`[name="${containerName}"]`, page)[0];
|
|
80
|
+
if (!container) return;
|
|
81
|
+
|
|
82
|
+
function walkLayers(layers) {
|
|
83
|
+
layers.forEach(layer => {
|
|
84
|
+
if (layer.type === 'Text') {
|
|
85
|
+
const fontName = String(layer.sketchObject.font().fontName());
|
|
86
|
+
const variant = ['Regular','Medium','Semibold','Bold','Light'].find(k => fontName.includes(k)) || 'Regular';
|
|
87
|
+
const weightMap = { Regular:'w400', Medium:'w500', Semibold:'w600', Bold:'w700', Light:'w300' };
|
|
88
|
+
console.log(`"${layer.name}": fontSize=${layer.style.fontSize}, fontWeight=FontWeight.${weightMap[variant]}, fontFace=${fontName}`);
|
|
89
|
+
}
|
|
90
|
+
if (layer.layers) walkLayers(layer.layers);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
walkLayers(container.layers || []);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
extractTextStyles('组件名称');
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## 关键原则
|
|
101
|
+
|
|
102
|
+
1. **永远不要根据 `style.fontWeight` 数字直接推断 Flutter FontWeight**
|
|
103
|
+
2. **必须通过 `layer.sketchObject.font().fontName()` 获取字体面名称**
|
|
104
|
+
3. 拿到字体面名称(如 `PingFangSC-Semibold`)后,取变体部分(`Semibold`)做映射
|
|
105
|
+
4. 不同字体族(SF Pro、Roboto)的映射规则相同,变体名称是通用的
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# Sketch SVG 导出:Bitmap 图层内嵌 base64 而非矢量路径
|
|
2
|
+
|
|
3
|
+
**问题标签**: `sketch`, `svg`, `bitmap`, `SvgPicture`, `Image.asset`, `图标导出`
|
|
4
|
+
**问题类型**: `资源处理错误`
|
|
5
|
+
**严重程度**: 中
|
|
6
|
+
**节省时间**: 多次排查 → 1 次识别
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## 问题描述
|
|
11
|
+
|
|
12
|
+
从 Sketch 导出 SVG 图标后,在 Flutter 中用 `SvgPicture.asset` 显示,但图标看起来模糊、不清晰,放大后出现像素化,或者根本无法正确着色。
|
|
13
|
+
|
|
14
|
+
## 问题根因
|
|
15
|
+
|
|
16
|
+
Sketch 在导出 Bitmap(位图/Image)类型图层时,即使选择 SVG 格式,**实际写入的是 base64 编码的 PNG 数据**,并非矢量路径:
|
|
17
|
+
|
|
18
|
+
```xml
|
|
19
|
+
<!-- 表面是 .svg 文件,实际内容完全不是矢量 -->
|
|
20
|
+
<svg viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
|
21
|
+
<image xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA..." width="12" height="12"/>
|
|
22
|
+
</svg>
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
用 `SvgPicture.asset` 渲染这种文件时:
|
|
26
|
+
- 实际显示的是 base64 内嵌的栅格图像,存在分辨率上限
|
|
27
|
+
- `colorFilter` 无法对内嵌图像染色(只对矢量 path 有效)
|
|
28
|
+
- 在高 DPI 屏幕上仍然模糊
|
|
29
|
+
|
|
30
|
+
## 如何识别 Bitmap 图层
|
|
31
|
+
|
|
32
|
+
```javascript
|
|
33
|
+
// Sketch 脚本:检测图层类型
|
|
34
|
+
const sketch = require('sketch');
|
|
35
|
+
const layer = sketch.getSelectedDocument().selectedLayers.layers[0];
|
|
36
|
+
|
|
37
|
+
console.log(`图层类型: ${layer.type}`);
|
|
38
|
+
// Shape / Group / Text / SymbolInstance → 矢量,可导出 SVG
|
|
39
|
+
// Image → 位图,应导出 PNG
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
也可以在 Sketch UI 中:点击图层 → 右侧 Inspector → 图层类型标识为 "Image" 则为 Bitmap。
|
|
43
|
+
|
|
44
|
+
## 正确方案
|
|
45
|
+
|
|
46
|
+
### 步骤一:导出 PNG 而非 SVG
|
|
47
|
+
|
|
48
|
+
```javascript
|
|
49
|
+
const sketch = require('sketch');
|
|
50
|
+
const layer = sketch.find('[name="ic_rate_chart"]', sketch.getSelectedDocument().selectedPage)[0];
|
|
51
|
+
|
|
52
|
+
if (layer) {
|
|
53
|
+
// 位图图层导出 PNG,@3x 保证高清
|
|
54
|
+
sketch.export(layer, {
|
|
55
|
+
formats: 'png',
|
|
56
|
+
scales: '3',
|
|
57
|
+
output: '/path/to/project/assets/icons/',
|
|
58
|
+
});
|
|
59
|
+
console.log('导出完成: ic_rate_chart@3x.png');
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### 步骤二:Flutter 使用 Image.asset
|
|
64
|
+
|
|
65
|
+
```dart
|
|
66
|
+
// ✅ 正确 - 位图图标用 Image.asset
|
|
67
|
+
Image.asset(
|
|
68
|
+
'assets/icons/ic_rate_chart.png',
|
|
69
|
+
width: 12,
|
|
70
|
+
height: 12,
|
|
71
|
+
color: AppColors.textDarkSecondary, // 单色染色
|
|
72
|
+
colorBlendMode: BlendMode.srcIn,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
// ✅ 不需要染色时
|
|
76
|
+
Image.asset(
|
|
77
|
+
'assets/icons/ic_rate_chart.png',
|
|
78
|
+
width: 12,
|
|
79
|
+
height: 12,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
// ❌ 错误 - 对 Bitmap SVG 用 SvgPicture,实际是渲染嵌套 PNG
|
|
83
|
+
SvgPicture.asset(
|
|
84
|
+
'assets/icons/ic_rate_chart.svg',
|
|
85
|
+
colorFilter: ColorFilter.mode(color, BlendMode.srcIn), // 不生效
|
|
86
|
+
)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### 步骤三:注册资源
|
|
90
|
+
|
|
91
|
+
在 `pubspec.yaml` 中确保声明:
|
|
92
|
+
|
|
93
|
+
```yaml
|
|
94
|
+
flutter:
|
|
95
|
+
assets:
|
|
96
|
+
- assets/icons/ic_rate_chart.png
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## 矢量图标 vs 位图图标的选择
|
|
100
|
+
|
|
101
|
+
| 图标来源 | 图层类型 | 导出格式 | Flutter 使用方式 |
|
|
102
|
+
|--------|--------|--------|----------------|
|
|
103
|
+
| Sketch Shape/Group 绘制 | Shape/Group | SVG | `SvgPicture.asset` |
|
|
104
|
+
| 粘贴进来的 PNG/JPG | Image | PNG (`scales: '3'`) | `Image.asset` |
|
|
105
|
+
| 外部导入的位图 | Image | PNG (`scales: '3'`) | `Image.asset` |
|
|
106
|
+
| 含 mask 的复合图标 | Group | SVG(先确认路径正确) | `SvgPicture.asset` |
|
|
107
|
+
|
|
108
|
+
## 关键原则
|
|
109
|
+
|
|
110
|
+
1. **导出前先检查图层类型**:`layer.type === 'Image'` 则为 Bitmap,必须用 PNG
|
|
111
|
+
2. **不要对内嵌 base64 SVG 使用 colorFilter**:颜色叠加对栅格图像无效
|
|
112
|
+
3. **PNG 导出用 `scales: '3'`**:保证 @3x 分辨率,避免在高 DPI 设备上模糊
|
|
@@ -670,7 +670,7 @@ function detectIfIcon(layer) {
|
|
|
670
670
|
|
|
671
671
|
var name = (layer.name || '').toLowerCase();
|
|
672
672
|
var size = Math.max(layer.frame.width, layer.frame.height);
|
|
673
|
-
var iconKeywords = ['icon', 'ico', 'logo', 'symbol', 'arrow', 'close', 'menu',
|
|
673
|
+
var iconKeywords = ['icon', 'ico', 'ic_', '_ic', 'logo', 'symbol', 'arrow', 'close', 'menu',
|
|
674
674
|
'search', 'star', 'check', 'back', 'more', 'nav', 'btn', 'button'];
|
|
675
675
|
var hasKeyword = false;
|
|
676
676
|
for (var k = 0; k < iconKeywords.length; k++) {
|
|
@@ -837,7 +837,13 @@ function extractShapePathSignature(layer) {
|
|
|
837
837
|
guessedRole = 'background';
|
|
838
838
|
guessedFlutter = 'Container with BoxDecoration';
|
|
839
839
|
}
|
|
840
|
-
//
|
|
840
|
+
// v3.0.6: 极简几何描边(≤4直线点,尺寸≤24×24)→ CustomPainter 而非 SVG
|
|
841
|
+
// 根因: AI 看到勾选/箭头路径会用 Icons.check,但 Material Icon 是填充形状,与描边设计不符
|
|
842
|
+
else if (!isClosed && !hasCurves && pointCount <= 4 && Math.max(w, h) <= 24) {
|
|
843
|
+
guessedRole = 'geometric-stroke';
|
|
844
|
+
guessedFlutter = 'CustomPainter.drawPath() - 禁止用 Icons.xxx 或 SvgPicture.asset()';
|
|
845
|
+
}
|
|
846
|
+
// 描边图标(复杂曲线或点数较多的开放路径)
|
|
841
847
|
else if (!isClosed && pointCount >= 2) {
|
|
842
848
|
guessedRole = 'icon-stroke';
|
|
843
849
|
guessedFlutter = 'SvgPicture.asset() - stroke path';
|
|
@@ -907,6 +913,18 @@ function extractShapePathSignature(layer) {
|
|
|
907
913
|
shapeSigResult.strokeSvgHint = '此路径是开放描边路径 (isClosed=false),SVG 必须用 stroke 而非 fill。' +
|
|
908
914
|
'模板: <polyline points="..." fill="none" stroke="' + strokeHexColor + '" stroke-width="' + b0.thickness + '" stroke-linecap="round" stroke-linejoin="round"/>。' +
|
|
909
915
|
'禁止生成 fill 实心形状——该图形在设计稿中是纯描边无填充。';
|
|
916
|
+
// v3.0.6: geometric-stroke 专用 CustomPainter 提示——无需 SVG 文件,直接内联绘制
|
|
917
|
+
if (guessedRole === 'geometric-stroke') {
|
|
918
|
+
shapeSigResult.flutterCustomPainterHint =
|
|
919
|
+
'此路径为极简几何描边(' + pointCount + '点,' + w + '×' + h + '),禁止用 Icons.xxx 或 SvgPicture.asset()。' +
|
|
920
|
+
'用 CustomPainter: canvas.drawPath(path, Paint()' +
|
|
921
|
+
'..color=' + strokeFlutterColor +
|
|
922
|
+
'..style=PaintingStyle.stroke' +
|
|
923
|
+
'..strokeWidth=' + b0.thickness +
|
|
924
|
+
'..strokeCap=StrokeCap.round' +
|
|
925
|
+
'..strokeJoin=StrokeJoin.round)。' +
|
|
926
|
+
'典型场景: radio button勾选✓、小型折线箭头。';
|
|
927
|
+
}
|
|
910
928
|
}
|
|
911
929
|
}
|
|
912
930
|
|
|
@@ -1279,7 +1297,11 @@ function extractCompleteStyle(layer) {
|
|
|
1279
1297
|
var ish = innerShadows[isi];
|
|
1280
1298
|
var innerObj = { color: ish.color, blur: ish.blur, x: ish.x, y: ish.y, spread: ish.spread || 0 };
|
|
1281
1299
|
var fc3 = sketchColorToFlutter(ish.color);
|
|
1282
|
-
if (fc3)
|
|
1300
|
+
if (fc3) {
|
|
1301
|
+
innerObj.flutterBoxShadow = 'BoxShadow(color: ' + fc3 + ', offset: Offset(' + ish.x + ', ' + ish.y + '), blurRadius: ' + ish.blur + ', spreadRadius: ' + (ish.spread || 0) + ')';
|
|
1302
|
+
// Flutter 不原生支持 innerShadow,以下给出实现方案
|
|
1303
|
+
innerObj.flutterNote = '⚠️ Flutter 不原生支持 innerShadow。实现方案: 在 Stack 中于内容层上方叠加一个同形状的 DecoratedBox,decoration 中 boxShadow 使用较大的 spreadRadius 使阴影向内延伸,再用 ClipRRect/ClipPath 裁剪为相同圆角,使阴影只在容器内部可见。或使用 CustomPainter 直接绘制。';
|
|
1304
|
+
}
|
|
1283
1305
|
style.innerShadows.push(innerObj);
|
|
1284
1306
|
}
|
|
1285
1307
|
}
|
|
@@ -2415,8 +2437,28 @@ function measureRecursively(layer, depth, parentFrame, siblings) {
|
|
|
2415
2437
|
if (iconFill.flutterColor) {
|
|
2416
2438
|
var iw = Math.round(layer.frame.width);
|
|
2417
2439
|
var ih = Math.round(layer.frame.height);
|
|
2440
|
+
// 将图层级别 opacity 合并进 colorFilter 颜色的 alpha 通道
|
|
2441
|
+
// 原因: Sketch 支持"填充色 alpha"和"图层整体 opacity"两个独立维度,
|
|
2442
|
+
// 若只用填充色 alpha 而忽略 layer opacity, colorFilter 渲染比设计稿更深
|
|
2443
|
+
var layerOpacity = 1.0;
|
|
2444
|
+
try {
|
|
2445
|
+
if (layer.style && layer.style.opacity !== undefined && layer.style.opacity !== 1) {
|
|
2446
|
+
layerOpacity = layer.style.opacity;
|
|
2447
|
+
}
|
|
2448
|
+
} catch (e2) { /* ignore */ }
|
|
2449
|
+
var fillAlpha = (iconFill.alpha !== undefined) ? iconFill.alpha : 1.0;
|
|
2450
|
+
var effectiveAlpha = fillAlpha * layerOpacity;
|
|
2451
|
+
var effectiveColor;
|
|
2452
|
+
if (Math.abs(effectiveAlpha - fillAlpha) > 0.01) {
|
|
2453
|
+
// layer opacity 有效,合并后重新生成 Flutter 颜色
|
|
2454
|
+
effectiveColor = toFlutterColor(iconFill.hex || iconFill.color, effectiveAlpha);
|
|
2455
|
+
element.iconOpacityMerged = true;
|
|
2456
|
+
element.iconOpacityNote = 'fill-alpha(' + Math.round(fillAlpha * 100) + '%) × layer-opacity(' + Math.round(layerOpacity * 100) + '%) = ' + Math.round(effectiveAlpha * 100) + '% → 已合并入 colorFilter alpha';
|
|
2457
|
+
} else {
|
|
2458
|
+
effectiveColor = iconFill.flutterColor;
|
|
2459
|
+
}
|
|
2418
2460
|
// 路径后附带 TODO 注释,提示核对实际文件名
|
|
2419
|
-
element.flutterSvgCode = 'SvgPicture.asset(\'assets/' + element.iconExportPath + '\' /* TODO: 核对 assets/icons/ 中实际文件名,禁止用 Icons.xxx 替代 */, width: ' + iw + ', height: ' + ih + ', colorFilter: ColorFilter.mode(' +
|
|
2461
|
+
element.flutterSvgCode = 'SvgPicture.asset(\'assets/' + element.iconExportPath + '\' /* TODO: 核对 assets/icons/ 中实际文件名,禁止用 Icons.xxx 替代 */, width: ' + iw + ', height: ' + ih + ', colorFilter: ColorFilter.mode(' + effectiveColor + ', BlendMode.srcIn),)';
|
|
2420
2462
|
}
|
|
2421
2463
|
}
|
|
2422
2464
|
}
|
|
@@ -2508,7 +2550,13 @@ function measureRecursively(layer, depth, parentFrame, siblings) {
|
|
|
2508
2550
|
if (nativeImgFills && nativeImgFills[0] && nativeImgFills[0].hex) {
|
|
2509
2551
|
element.imageInfo.nativeTint = formatNativeColor(nativeImgFills[0]);
|
|
2510
2552
|
}
|
|
2511
|
-
element.imageInfo.note = '此图层为嵌入式位图(Bitmap),
|
|
2553
|
+
element.imageInfo.note = '此图层为嵌入式位图(Bitmap), 必须导出为 PNG (非SVG——Bitmap导出SVG只含base64嵌套图片, 无矢量路径), 放入 assets/icons/, 用 Image.asset 渲染';
|
|
2554
|
+
// 生成 Image.asset 代码片段,开发者直接复制使用
|
|
2555
|
+
var bitmapW = Math.round(layer.frame.width);
|
|
2556
|
+
var bitmapH = Math.round(layer.frame.height);
|
|
2557
|
+
var bitmapName = sanitizeFileName(layer.name);
|
|
2558
|
+
element.flutterImageCode = 'Image.asset(\'assets/icons/' + bitmapName + '.png\', width: ' + bitmapW + ', height: ' + bitmapH + ', color: YOUR_COLOR, colorBlendMode: BlendMode.srcIn,)';
|
|
2559
|
+
element.imageInfo.exportHint = 'Sketch 导出命令: File > Export Layers > 选择此图层 > 格式 PNG @3x, 放入 assets/icons/';
|
|
2512
2560
|
}
|
|
2513
2561
|
|
|
2514
2562
|
// Symbol Instance — override 真实值
|
|
@@ -1153,6 +1153,10 @@ function extractShapePathSignature(layer) {
|
|
|
1153
1153
|
} else if (pointCount <= 4 && isClosed && hasCurves && aspect > 0.8 && aspect < 1.25) {
|
|
1154
1154
|
guessedRole = 'circle-bg'
|
|
1155
1155
|
guessedFlutter = 'Container(decoration: BoxDecoration(shape: BoxShape.circle, ...))'
|
|
1156
|
+
} else if (!isClosed && !hasCurves && pointCount <= 4 && w <= 24 && h <= 24) {
|
|
1157
|
+
// v3.0.6: 极简几何描边(≤4直线点,≤24×24),用 CustomPainter 而非 Icons.xxx 或 SVG
|
|
1158
|
+
guessedRole = 'geometric-stroke'
|
|
1159
|
+
guessedFlutter = '极简几何描边(' + pointCount + '点,' + w + '×' + h + ')。禁止用 Icons.xxx 或 SvgPicture.asset()。正确: CustomPainter → canvas.drawPath(path, Paint()..style=PaintingStyle.stroke..strokeCap=StrokeCap.round)。详见 flutterCustomPainterHint 字段。'
|
|
1156
1160
|
} else if (isSmall && isSquarish && (pointCount > 4 || !isClosed)) {
|
|
1157
1161
|
guessedRole = 'icon'
|
|
1158
1162
|
guessedFlutter = '矢量图标路径。必须 exportIconAsSVG 导出后用 SvgPicture.asset() 加载。禁止用 Icons.xxx 猜测。'
|
|
@@ -1167,6 +1171,25 @@ function extractShapePathSignature(layer) {
|
|
|
1167
1171
|
sig.guessedRole = guessedRole
|
|
1168
1172
|
sig.guessedFlutter = guessedFlutter
|
|
1169
1173
|
|
|
1174
|
+
// v3.0.6: 开放路径—提取描边信息;geometric-stroke 补充 CustomPainter 提示
|
|
1175
|
+
if (!isClosed && layer.style && layer.style.borders) {
|
|
1176
|
+
const activeBorders = layer.style.borders.filter(b => b.enabled !== false)
|
|
1177
|
+
if (activeBorders.length > 0) {
|
|
1178
|
+
const b0 = activeBorders[0]
|
|
1179
|
+
const fColor = sketchColorToFlutter(b0.color) || 'Colors.black'
|
|
1180
|
+
sig.strokeInfo = { thickness: b0.thickness, color: b0.color, flutterColor: fColor }
|
|
1181
|
+
if (guessedRole === 'geometric-stroke') {
|
|
1182
|
+
sig.flutterCustomPainterHint =
|
|
1183
|
+
'canvas.drawPath(path, Paint()' +
|
|
1184
|
+
'..color = ' + fColor +
|
|
1185
|
+
' ..style = PaintingStyle.stroke' +
|
|
1186
|
+
' ..strokeWidth = ' + (b0.thickness || 2) +
|
|
1187
|
+
' ..strokeCap = StrokeCap.round' +
|
|
1188
|
+
' ..strokeJoin = StrokeJoin.round)'
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1170
1193
|
return sig
|
|
1171
1194
|
} catch (e) {
|
|
1172
1195
|
return { error: String(e) }
|