用户逐条报的观感问题(都在"视觉观感"那一栏,不是功能缺失):
① 侧边栏图标"莫名其妙的加粗"
根因: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 那一半**。
455 lines
23 KiB
Python
455 lines
23 KiB
Python
#!/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())
|