Files
MailUI4Agents/client/harmony/tools/gen-icons.py
JianFeeeee f0dc342c1f 鸿蒙|图标 fill 型分族 + 页签条阴影根因修复 + 生成器入库
用户逐条报的观感问题(都在"视觉观感"那一栏,不是功能缺失):

① 侧边栏图标"莫名其妙的加粗"
   根因:43 个图标里**只有品牌标 `brandMark` 是 fill 型**
   (`fill="currentColor"` + 两个实心 `<circle r=".9">`),
   它自己的注释就写着「fill 型,与上面的描边图标集**不同族**,所以不包 Svg」。
   而 `AmIcon` 一律 `fill(Transparent) + stroke()` ⇒ 实心块只剩轮廓、
   实心眼变成小圆环、圆头端点在小尺寸下糊成一坨。
   修法:生成器自动判定两族(`is_fill_family`),产出 `FILL_ICONS` 名单,
   fill 族整块填色、不加描边。

② 详情页权限面板"左边被截断"
   `PermissionPanel` 挂在详情页外层,根部只有 `padding({top:10})` ——
   而正文 `Markdown` 与附件块**各自自带** `left/right:16`。补上同档 16。

③ 顶栏"莫名其妙的底部阴影"
   ★ 这条查错了两轮,记下来:
   · 先以为是窗格投影从半透明玻璃透上来 → 补底边、去材质 —— 都没用;
   · 几何取证才定位真因:`InboxTab` 根容器与页签条是 `Column` 里的**兄弟**
     且**绘制在后**(页签条 y 142→271,InboxTab y 271→2202),
     它的 `PaneModifier` 投影向上扩散 ~30px,正好压住页签条
     (观测到的渐变区 y 240→268,完全吻合)。
   · 双向验证:关掉所有窗格投影 → 全平(证明确实是投影);
     只把 `InboxTab` 换 `plain` → 落差 21→8(证明是**这一层**)。
   修法:`PaneModifier` 加 `plain()`(只要背景语义、不投投影)。
   WebUI 的 `box-shadow` 只给 `.app-shell > *`(三个**并列**面板),
   面板内部不投 —— 这个结构差异就是原因。

   ★ 同时保留页签条的**悬浮玻璃**(用户 09-20 点名要的):
   我中途一度按"跟 webui 同步"把它改成不透明实体面,
   那是**读错了**——用户指的是页面结构对齐,而玻璃是他自己定过的;
   两者不冲突(玻璃是观感选择,阴影是 bug)。已恢复并留注释。

④ 生成器入库(`client/harmony/tools/gen-icons.py`)
   它原先在仓库外(`/root/gotmp`),于是**落后生成物三次提交**没人发现:
   我手改 `Icons.ets` 修 fill 图标后重跑它,修改被**冲掉**(退回旧实现)。
   现在:路径按 `__file__` 解析(任何检出目录都能跑)、
   `is_fill_family` 自动分族、输出段与正确实现逐字一致(diff 为空)。
   `Icons.ets` 由它生成,不再手改。

判据:
· `harmony-appearance` 29 条(+1):窗格投影只给并列窗格,
  面板内部的子 tab 不得再投。按 **struct 归属**判(第一版计数有洞,
  变异①抓不到 —— 改回 `of` 时同文件别处的 `plain` 仍让它绿)。
  两个变异都能红:① InboxTab 改回 of;② 让 plain 也投投影。
  另修一条既有判据的假红:它数 `PaneModifier.of` 出现次数,
  加 `plain` 后误报("让出页面底"的两种合法写法都要算)。
· 套件:harmony-nav 22/22、harmony-appearance 28/28、
  harmony-logic 34/34、harmony-contacts 5/5、animation-audit 15/15。
**功能对齐状态**(回答用户"功能对齐没有"):
  对着 `docs/DEBTS.json` 逐条核过 —— 鸿蒙侧**没有缺页面、没有缺功能**。
  授权栏历史(`harmony-permission-history`)✅ 已做、
  详情页转发/改名建议/预算(`harmony-maildetail-missing-three`)✅ 三块全做完。
  仅剩两条,都不在鸿蒙:`harmony-p4c-boundary-decls` 卡在"需真人操作系统选图器"、
  `permission-expires-at-unused` 缺的是 **WebUI 那一半**。
2026-09-24 13:13:46 +08:00

455 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""把 WebUI 的 icons.tsx(24×24 描边 SVG)转成 ArkUI Path 的 commands 字符串。
为什么要转而不是直接放 SVG 文件:
· WebUI 的图标是 **stroke** 画法(fill=none, stroke=currentColor);鸿蒙的
`Image().fillColor()` 只改 **fill**,对描边无效 ⇒ 直接放 SVG 会丢主题色;
· ArkUI 的 `Path` 是原生矢量图元,`.stroke(颜色)` 跟主题走,几何与 WebUI 逐字一致。
风险点与对策:
· ArkUI 的 commands 解析器**不支持 SVG 的隐式重复命令**(`m22 2-7 20` 这种)
⇒ 本脚本展开成显式命令;
· 相对命令(小写)语义易错 ⇒ 本脚本**统一转成绝对命令**(A 的 rx/ry/rot/flag 不变,
只有终点坐标转换);
· `A/a` 的两个 flag 允许与后续数字粘连(`a1 1 0 011 1`)⇒ tokenizer 特判拆分。
"""
import re
import sys
# ★★ 2026-09-24 改为**相对本脚本**的路径(原来写的是 /root/gotmp 下的绝对路径)。
#
# 理由:这个脚本现在**进了仓库**(`client/harmony/tools/gen-icons.py`),
# 而写死绝对路径会让任何别的检出目录都跑不起来 —— 与同族的
# `client/electron/src/icons/generate.py` 一样,按 `__file__` 解析。
#
# 之前它在仓库外(`/root/gotmp`),于是**生成器落后于生成物**整整三次提交都没人发现:
# 2026-09-24 我手改 `Icons.ets` 修 fill 型图标,重跑生成器时它把修改**冲掉了**
# (退回 `.width(ICON_VIEWBOX)` + `this.iconSize / ICON_VIEWBOX` 的旧实现)。
# 生成器与生成物分家,产物就只是一份"看起来像生成的"手写文件。
from pathlib import Path
_HERE = Path(__file__).resolve().parent # client/harmony/tools
_REPO = _HERE.parent.parent.parent # 仓库根
SRC = str(_REPO / 'client/electron/src/components/icons.tsx')
OUT = str(_REPO / 'client/harmony/entry/src/main/ets/common/Icons.ets')
ARITY = {'M': 2, 'L': 2, 'H': 1, 'V': 1, 'C': 6, 'S': 4, 'Q': 4, 'T': 2, 'A': 7, 'Z': 0}
NUM = re.compile(r'[MmLlHhVvCcSsQqTtAaZz]|-?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?')
def tokenize(d: str) -> list:
"""SVG path → [(cmd, [args])],展开隐式重复(含 M 后隐式 L 规则)。"""
toks = NUM.findall(d)
out = []
i = 0
prev = None
while i < len(toks):
t = toks[i]
if re.match(r'[A-Za-z]', t):
cmd = t
i += 1
else:
if prev is None:
raise ValueError('path 以数字开头: ' + d)
cmd = 'L' if prev == 'M' else ('l' if prev == 'm' else prev)
up = cmd.upper()
k = ARITY[up]
args = []
if up == 'A':
# rx ry rot 之后是两个 flag(可能是单字符 '0'/'1',也可能与后面的数字粘连)
for _ in range(3):
args.append(float(toks[i]))
i += 1
for _ in range(2):
tok = toks[i]
if len(tok) > 1 and tok[0] in '01':
args.append(float(tok[0]))
toks[i] = tok[1:]
else:
args.append(float(tok))
i += 1
args.append(float(toks[i]))
i += 1
args.append(float(toks[i]))
i += 1
else:
for _ in range(k):
args.append(float(toks[i]))
i += 1
out.append((cmd, args))
prev = cmd
return out
def fmt(v: float) -> str:
s = ('%.4f' % v).rstrip('0').rstrip('.')
return '0' if s in ('', '-0') else s
def to_absolute(cmds: list) -> str:
"""相对 → 绝对;输出 ArkUI commands。"""
px = py = 0.0
sx = sy = 0.0 # 子路径起点(Z 之后当前点回到起点)
out = []
for cmd, a in cmds:
up = cmd.upper()
rel = cmd.islower()
if up == 'M':
x = (px + a[0]) if rel else a[0]
y = (py + a[1]) if rel else a[1]
px, py = x, y
sx, sy = x, y
out.append('M ' + fmt(x) + ' ' + fmt(y))
elif up == 'L':
x = (px + a[0]) if rel else a[0]
y = (py + a[1]) if rel else a[1]
px, py = x, y
out.append('L ' + fmt(x) + ' ' + fmt(y))
elif up == 'H':
x = (px + a[0]) if rel else a[0]
px = x
out.append('L ' + fmt(x) + ' ' + fmt(py))
elif up == 'V':
y = (py + a[0]) if rel else a[0]
py = y
out.append('L ' + fmt(px) + ' ' + fmt(y))
elif up == 'C':
x1 = (px + a[0]) if rel else a[0]
y1 = (py + a[1]) if rel else a[1]
x2 = (px + a[2]) if rel else a[2]
y2 = (py + a[3]) if rel else a[3]
x = (px + a[4]) if rel else a[4]
y = (py + a[5]) if rel else a[5]
px, py = x, y
out.append('C ' + ' '.join(fmt(v) for v in (x1, y1, x2, y2, x, y)))
elif up == 'S':
x2 = (px + a[0]) if rel else a[0]
y2 = (py + a[1]) if rel else a[1]
x = (px + a[2]) if rel else a[2]
y = (py + a[3]) if rel else a[3]
px, py = x, y
out.append('S ' + ' '.join(fmt(v) for v in (x2, y2, x, y)))
elif up == 'Q':
x1 = (px + a[0]) if rel else a[0]
y1 = (py + a[1]) if rel else a[1]
x = (px + a[2]) if rel else a[2]
y = (py + a[3]) if rel else a[3]
px, py = x, y
out.append('Q ' + ' '.join(fmt(v) for v in (x1, y1, x, y)))
elif up == 'T':
x = (px + a[0]) if rel else a[0]
y = (py + a[1]) if rel else a[1]
px, py = x, y
out.append('Q ' + fmt(px) + ' ' + fmt(py) + ' ' + fmt(x) + ' ' + fmt(y))
elif up == 'A':
x = (px + a[5]) if rel else a[5]
y = (py + a[6]) if rel else a[6]
px, py = x, y
out.append('A ' + ' '.join([fmt(a[0]), fmt(a[1]), fmt(a[2]), str(int(a[3])), str(int(a[4])), fmt(x), fmt(y)]))
elif up == 'Z':
px, py = sx, sy
out.append('Z')
return ' '.join(out)
def circle_to_path(cx, cy, r) -> str:
return to_absolute(tokenize(
f'M {cx - r} {cy} A {r} {r} 0 1 0 {cx + r} {cy} A {r} {r} 0 1 0 {cx - r} {cy} Z'))
def rect_to_path(x, y, w, h, rx) -> str:
if rx <= 0:
return to_absolute(tokenize(f'M {x} {y} H {x + w} V {y + h} H {x} Z'))
return to_absolute(tokenize(
f'M {x + rx} {y} H {x + w - rx} A {rx} {rx} 0 0 1 {x + w} {y + rx} '
f'V {y + h - rx} A {rx} {rx} 0 0 1 {x + w - rx} {y + h} '
f'H {x + rx} A {rx} {rx} 0 0 1 {x} {y + h - rx} '
f'V {y + rx} A {rx} {rx} 0 0 1 {x + rx} {y} Z'))
def is_fill_family(chunk: str) -> bool:
"""这个图标是 **fill 族**还是描边族?
★★ 2026-09-24 新增。用户:「为什么侧边栏图标有莫名其妙的加粗?」
── 为什么会漏掉这一族 ──
本脚本原先假定**所有**图标都是描边画法(源用 `<Svg>` helper,即
`fill="none" stroke="currentColor" strokeWidth="1.8"`),于是只产出一条
`.fill(Transparent) + .stroke(主题色)` 的绘制路径。而 `icons.tsx` 里
有一个**不包 `Svg`** 的例外 —— `BrandMarkIcon`:
<svg viewBox="0 0 24 24" fill="currentColor" …> ← 自己写 svg,不用 helper
<path d="…" />
<circle cx="9" cy="11.6" r=".9" /> ← 实心眼
<circle cx="15" cy="11.6" r=".9" />
</svg>
它自己的注释就写着「品牌标志(fill 型,与上面的描边图标集**不同族**,
所以不包 Svg)」。把实心块当描边画 ⇒ 只剩轮廓、两个实心眼变成小圆环,
小尺寸下糊成一坨(实测侧栏 3× 放大截图一眼可见)。
── 判据 ──
两族的区别是**事实**(源用了哪种画法),不是可以从几何猜出来的:
`inbox`/`calendar` 这些描边图标也有闭合子路径。所以只认一件事:
这个函数体里有没有 `<Svg` —— 用了 helper = 描边族,自己写 `<svg` = 看它的
`fill=` 属性(`currentColor` ⇒ fill 族;`none` ⇒ 描边族)。
"""
if '<Svg' in chunk:
return False
m = re.search(r'<svg[^>]*\bfill="([^"]+)"', chunk)
return bool(m) and m.group(1).strip() == 'currentColor'
def attr(tag: str, name: str, default=None):
m = re.search(name + r'="([^"]*)"', tag)
if not m:
if default is None:
raise KeyError(f'{name} 缺失于 {tag}')
return default
return float(m.group(1))
def main() -> int:
s = open(SRC, encoding='utf-8').read()
starts = [m.start() for m in re.finditer(r'export function (\w+)\(', s)]
starts.append(len(s))
icons = {}
fill_icons = []
order = []
for i in range(len(starts) - 1):
chunk = s[starts[i]:starts[i + 1]]
name = re.match(r'export function (\w+)\(', chunk).group(1)
if name == 'Svg':
continue
parts = []
# 元素按源码顺序(描边是叠加的,顺序无关但保持一致便于比对)
for m in re.finditer(r'<(path|circle|rect)\b([^>]*?)/?>', chunk):
kind, tag = m.group(1), m.group(0)
if kind == 'path':
d = re.search(r'd="([^"]+)"', tag).group(1)
parts.append(to_absolute(tokenize(d)))
elif kind == 'circle':
parts.append(circle_to_path(attr(tag, 'cx'), attr(tag, 'cy'), attr(tag, 'r')))
else:
x = attr(tag, 'x', '0.0') if 'x="' in tag else 0.0
y = attr(tag, 'y', '0.0') if 'y="' in tag else 0.0
w = attr(tag, 'width')
h = attr(tag, 'height')
rx = attr(tag, 'rx', '0.0') if 'rx="' in tag else 0.0
parts.append(rect_to_path(x, y, w, h, rx))
key = re.sub(r'Icon$', '', name)
key = key[0].lower() + key[1:]
icons[key] = ' '.join(parts)
if is_fill_family(chunk):
fill_icons.append(key)
order.append((name, key))
# 自身校验:命令字形与参数个数必须与 ARITY 完全对上
bad = []
for k, v in icons.items():
toks = re.findall(r'[A-Za-z]|-?(?:\d+\.?\d*|\.\d+)', v)
i = 0
while i < len(toks):
t = toks[i]
if not re.match(r'[A-Za-z]', t):
bad.append((k, '非命令位置出现数字: ' + t))
break
n = ARITY[t.upper()]
args = toks[i + 1:i + 1 + n]
if len(args) != n or any(re.match(r'[A-Za-z]', a) for a in args):
bad.append((k, f'{t} 期望 {n} 个参数,实得 {len(args)}'))
break
i += 1 + n
if len(v.strip()) == 0:
bad.append((k, '空路径'))
if bad:
print('自身校验失败:')
for b in bad:
print(' ', b)
return 1
'''
撞名自检:ArkUI 把一批名字当**通用属性**(`size`/`width`/`height`/`scale`/`opacity`…),
组件成员用这些名字会直接编译失败(实测:`Property 'size' in type 'AmIcon' is not
assignable to the same property in base type 'CustomComponent'`)。
所有成员名统一加 `icon`/`stroke` 前缀,这里再挡一道 —— 因为"重新生成"是常态操作,
靠人记住这条约定迟早破。
'''
BANNED = ['size', 'width', 'height', 'scale', 'opacity', 'backgroundColor', 'padding',
'margin', 'borderRadius', 'border', 'position', 'visibility', 'enabled', 'color',
'name', 'id', 'key', 'clip', 'zIndex']
for p in ['iconName', 'iconSize', 'iconColor', 'strokeWeight']:
if p in BANNED:
print('撞名自检失败:`%s` 是 ArkUI 通用属性名' % p)
return 1
# 源里出现 T/Q 就停下:本脚本对 T(平滑二次曲线,控制点需反射上一点)**没有实现**,
# 而源目前用的是 a/A/c/C/h/H/l/L/m/M/s/v/V/z/Z —— 将来源改了必须来看这一行,
# 而不是默默产出一个形状不对的图标。
for cmd in ['T', 't', 'Q', 'q']:
if re.search(r'[TtQq]', ''.join(
re.findall(r'd="([^"]+)"', s))):
print('源里出现 %s 命令,本脚本未实现(见注释)—— 请先补 to_absolute' % cmd)
return 1
lines = []
lines.append('/*')
lines.append(' * 图标集 —— **与 WebUI 的 `client/electron/src/components/icons.tsx` 同几何**。')
lines.append(' *')
lines.append(' * 由 `client/harmony/tools/gen-icons.py` 从那份 TSX **自动生成**,不要手改:')
lines.append(' * · 源是 24×24 SVG,分**两族**(见 `FILL_ICONS`):描边族')
lines.append(' * (`fill=none / stroke=currentColor / strokeWidth 1.8 / 圆头圆角`)与')
lines.append(' * fill 族(`fill=currentColor`,品牌标 `brandMark` 一个);')
lines.append(' * · 转成 ArkUI `Path` 的 commands,并**统一为绝对命令**、展开隐式重复')
lines.append(' * (ArkUI 的解析器不认 SVG 的隐式 lineto);')
lines.append(' * · `circle`/`rect` 也展开成弧与直线。')
lines.append(' *')
lines.append(' * 为什么不用 SVG 资源文件:WebUI 的图标大多是**描边**画法,而鸿蒙 `Image().fillColor()`')
lines.append(' * 只改 fill、对 stroke 无效 ⇒ 直接放 SVG 图标就不跟主题走。用 `Path` 则 `.stroke()`')
lines.append(' * 直接吃主题色(这是"深色模式一处也不漏"的前提)。')
lines.append(' *')
lines.append(' * 生成物共 %d 个图标(其中 fill 族 %d 个);生成时已自检:每条命令的参数个数与 SVG 语法一致。'
% (len(icons), len(fill_icons)))
lines.append(' */')
lines.append('')
lines.append("import { Theme } from './Theme';")
lines.append('')
lines.append('/** 图标名 → ArkUI Path commands(24×24 坐标系,绝对命令) */')
lines.append('export const ICON_PATHS: Record<string, string> = {')
for _, key in order:
lines.append(" '%s': '%s'," % (key, icons[key]))
lines.append('};')
lines.append('')
lines.append('/** 图标原始坐标系边长(所有图标都在这个方框内绘制) */')
lines.append('export const ICON_VIEWBOX: number = 24;')
lines.append('')
lines.append('/**')
lines.append(' * **fill 型**图标的名单 —— 它们必须整块填色,不能当描边画。')
lines.append(' *')
lines.append(' * ★★ 2026-09-24 新增(用户:「为什么侧边栏图标有莫名其妙的加粗?」)。')
lines.append(' *')
lines.append(' * WebUI 的图标集(`icons.tsx`)分两族:')
lines.append(' * · **描边型**(%d 个):包在 `<Svg>` helper 里,即'
% (len(icons) - len(fill_icons)))
lines.append(' * `fill="none" stroke="currentColor" strokeWidth="1.8"`;')
lines.append(' * · **fill 型**(%d 个):自己写 `<svg fill="currentColor">`,含**实心**元素'
% len(fill_icons))
lines.append(' * (如 `<circle r=".9">`)。`BrandMarkIcon` 自己的注释就写着')
lines.append(' * 「品牌标志(fill 型,与上面的描边图标集**不同族**,所以不包 Svg)」。')
lines.append(' *')
lines.append(' * 而本仓的 `AmIcon` 一律 `fill(Color.Transparent) + stroke(...)` ——')
lines.append(' * 那是**描边型**的画法。于是 fill 型图标:实心块只剩轮廓、实心眼变成小圆环,')
lines.append(' * 再加 `strokeLineCap: Round`,小尺寸下糊成一团(实测侧栏 3× 放大截图一眼可见)。')
lines.append(' *')
lines.append(' * ── 为什么是名单而不是靠路径猜 ──')
lines.append(' * 两族的区别是**源用了哪种画法**这个事实,不是几何能推出来的:')
lines.append(' * `inbox`/`calendar` 这些描边图标也有闭合子路径 —— 猜会误伤一大片。')
lines.append(' * 由生成器从 `<Svg>` helper 的有无自动判定(见脚本 `is_fill_family`),')
lines.append(' * 所以名单不会因手改而漂。')
lines.append(' */')
lines.append('export const FILL_ICONS: string[] = [%s];' % ', '.join("'%s'" % k for k in fill_icons))
lines.append('')
lines.append('/**')
lines.append(' * 图标组件:与 WebUI `<Icon className="w-5 h-5" />` 等价的用法。')
lines.append(' *')
lines.append(' * `size` 是**最终视觉尺寸**;内部按 24 坐标系绘制再等比缩放,')
lines.append(' * 所以任何尺寸都不会出现"描边变粗/变细"以外的偏差(描边宽度按 WebUI 的 1.8 固定)。')
lines.append(' */')
lines.append('@Component')
lines.append('export struct AmIcon {')
lines.append(" /** 图标名(见 ICON_PATHS 的键):inbox / sent / compose / mail / person … */")
lines.append(" @Prop iconName: string = '';")
lines.append(' /** 视觉尺寸(vp)。**不叫 `size`**:ArkUI 把 `size` 当通用属性名,撞名编译不过 */')
lines.append(' @Prop iconSize: number = 20;')
lines.append(' /** 描边颜色(默认取主题主文字色) */')
lines.append(' @Prop iconColor: ResourceColor = Theme.textPrimary;')
lines.append(' /** 描边宽度(WebUI 用 1.8) */')
lines.append(' @Prop strokeWeight: number = 1.8;')
lines.append('')
lines.append(' /** 这个图标是不是 fill 族(整块填色)。名单由生成器产出,见 `FILL_ICONS`。 */')
lines.append(' private isFillIcon(): boolean {')
lines.append(' return FILL_ICONS.indexOf(this.iconName) >= 0;')
lines.append(' }')
lines.append('')
lines.append(' /**')
lines.append(' * 把 24 单位坐标系落到 `iconSize` vp 上所需的缩放比。')
lines.append(' * Path.commands 的坐标是 px:24 单位需放大到 vp2px(iconSize) 个 px。')
lines.append(' *')
lines.append(' * 用 `UIContext#vp2px` 而不是全局 `vp2px`:后者已被 SDK 标废弃,')
lines.append(' * `harmony-system-api` 那条判据会对全局调用默认判红。')
lines.append(' */')
lines.append(' private iconScale(size: number): number {')
lines.append(' return this.getUIContext().vp2px(size) / ICON_VIEWBOX;')
lines.append(' }')
lines.append('')
lines.append(' /**')
lines.append(' * `scale()` 会把描边一并放大,所以要先把描边除掉这一层。')
lines.append(' * 目标视觉描边 = strokeWeight × (iconSize / 24) vp(与 WebUI 同比例)。')
lines.append(' * 而 strokeWidth 数字按 vp 解释:设 X vp → X*vp2px(1) px → 再乘 scale。')
lines.append(' * 解出 X = strokeWeight / vp2px(1)。')
lines.append(' */')
lines.append(' private iconStrokeWidth(): number {')
lines.append(' return this.strokeWeight / this.getUIContext().vp2px(1);')
lines.append(' }')
lines.append('')
lines.append(' build() {')
lines.append(' /*')
lines.append(' * ★ Path.commands 的坐标是 **px**,不是 vp —— 官方 Shape/viewPort 示例里缩放成立,')
lines.append(' * 是因为它用 Rect/Circle(自带 vp 宽高),而不是裸 `Path.commands`。')
lines.append(' * 实测:iconSize=24 时 Path 只画 24px(≈6.9vp),而等宽 Shape 盒是 24vp=84px,')
lines.append(' * 于是图标小而偏左上(截图硬证:bbox 24×24,中心偏 FAB 中心 (-30,-30))。')
lines.append(' * ⇒ 用 `vp2px(iconSize)/ICON_VIEWBOX` 把 24 单位路径等比放大到 iconSize **vp**。')
lines.append(' * 注意 scale 会连带放大描边(实图曾因此变成粗块),故描边先除掉同一系数。')
lines.append(' * `viewPort` 不再需要:它只裁剪不缩放。')
lines.append(' */')
lines.append(' Stack({ alignContent: Alignment.Center }) {')
lines.append(' Path()')
lines.append(" .commands(ICON_PATHS[this.iconName] ?? '')")
lines.append(' /*')
lines.append(' * ★★ 2026-09-24:两族图标分道 —— 见 `FILL_ICONS` 的长注释。')
lines.append(' * 描边型(绝大多数):透明填充 + 主题色描边;')
lines.append(' * fill 型:整块填主题色、**不加描边**')
lines.append(' * (给它描边会把实心块勾出一圈,且实心眼会变成圆环)。')
lines.append(' */')
lines.append(' .fill(this.isFillIcon() ? this.iconColor : Color.Transparent)')
lines.append(' .stroke(this.isFillIcon() ? Color.Transparent : this.iconColor)')
lines.append(' .strokeWidth(this.iconStrokeWidth())')
lines.append(' .strokeLineCap(LineCapStyle.Round)')
lines.append(' .strokeLineJoin(LineJoinStyle.Round)')
lines.append(' .scale({ x: this.iconScale(this.iconSize), y: this.iconScale(this.iconSize) })')
lines.append(' }')
lines.append(' /*')
lines.append(' * ★★ 2026-09-18:这里原来是 `.width(this.iconSize).height(this.iconSize)`,')
lines.append(' * 现在**显式保持默认尺寸**,同时把“调用方给更大盒子”的做法钉成错误用法。')
lines.append(' *')
lines.append(' * 曾经的陷阱:调用方写 `AmIcon({…}).width(48).height(48)`(想要一个大点的可点区域)——')
lines.append(' * 外层盒子变大了,而**内部这个容器的尺寸不变、且默认靠左上** ⇒ 图标贴在盒子左上角。')
lines.append(' *')
lines.append(' * 实测(登录页品牌卡,三折叠 3.5 密度,`dumpLayout` 读实际 bounds):')
lines.append(' * 卡片 [1523,521][1661,659] 138×138px')
lines.append(' * 图标 [1526,524][1589,587] 63×63px')
lines.append(' * ⇒ 中心偏了 10vp(用户:「你自己看看那个图标的位置正常吗」)。')
lines.append(' * 同样写法在仓里有 4 处(悬浮加号/返回键/刷新键/品牌卡),只是图标小时偏得不明显。')
lines.append(' *')
lines.append(' * 为什么不在这里写 `width(\'100%\')` 来适配:那会让**所有没显式给尺寸的调用点**')
lines.append(' * (绝大多数)在 `Row` 里试图撑满父容器 —— 那是拿一个普遍存在的布局回归')
lines.append(' * 换四个特例的方便。正确做法是调用方要“大盒子”就自己套一层居中的容器')
lines.append(' * (见 `LoginPage` 品牌卡与 `heroIcon` 的用法),组件本身只管画好这 iconSize 那么大的图标。')
lines.append(' */')
lines.append(' .width(this.iconSize)')
lines.append(' .height(this.iconSize)')
lines.append(' }')
lines.append('}')
lines.append('')
open(OUT, 'w', encoding='utf-8').write('\n'.join(lines))
print(' 写出 %s' % OUT)
print(' 图标 %d 个:%s' % (len(order), ' '.join(k for _, k in order)))
print(' fill 族 %d 个:%s' % (len(fill_icons), ' '.join(fill_icons) or '(无)'))
print(' 自检通过:命令/参数个数全部与 SVG 语法一致')
print(' 样例 inbox: %s' % icons['inbox'][:110])
return 0
if __name__ == '__main__':
sys.exit(main())