Files

169 lines
11 KiB
Python

"""Deterministic review assets for Lemana VPN; does not modify the application."""
from pathlib import Path
import html
import json
import math
import subprocess
import zipfile
ROOT = Path(__file__).resolve().parent
SHIELD = 'M12 2 C14.6 3.7 17.3 4.7 20 5 V11.2 C20 16.1 16.7 19.6 12 22 C7.3 19.6 4 16.1 4 11.2 V5 C6.7 4.7 9.4 3.7 12 2 Z'
def curve_length(points):
previous=points[0]; length=0.0
for i in range(1,513):
t=i/512; u=1-t
point=tuple(u**3*points[0][k]+3*u*u*t*points[1][k]+3*u*t*t*points[2][k]+t**3*points[3][k] for k in (0,1))
length+=math.dist(previous,point); previous=point
return length
SHIELD_LENGTH=2*(6.2+curve_length(((12,2),(14.6,3.7),(17.3,4.7),(20,5)))+curve_length(((20,11.2),(20,16.1),(16.7,19.6),(12,22))))
PALETTES = {
'light': dict(bg='#F6F7F8', surface='#ECEFF2', ink='#27333E', muted='#657381', line='#D6DDE3', green='#278055', amber='#A56A13', red='#BE4149'),
'dark': dict(bg='#202429', surface='#2B3037', ink='#E5EAF0', muted='#ACB7C3', line='#444D57', green='#78CAA2', amber='#F0BB68', red='#F58C95'),
}
def path(d, **attrs):
extra = ''.join(f' {key.replace("_", "-")}="{value}"' for key,value in attrs.items())
return f'<path d="{d}"{extra}/>'
def circle(x,y,r,**attrs):
extra = ''.join(f' {key.replace("_", "-")}="{value}"' for key,value in attrs.items())
return f'<circle cx="{x}" cy="{y}" r="{r}"{extra}/>'
def progress_interior(shape,progress,bottom,height,palette,accent):
body=shape(fill=palette['ink'],fill_opacity='.14',stroke='none')
if progress:
level=height*progress
top=bottom-level
# A nested viewport clips the fill level without shared SVG clip-path IDs.
body+=f'<svg x="0" y="{top:.5f}" width="24" height="{level:.5f}" viewBox="0 {top:.5f} 24 {level:.5f}" overflow="hidden">'
body+=shape(fill=accent,fill_opacity='.60',stroke='none')+'</svg>'
return body
MARKS = {
'off': path('M8.7 8.7 L15.3 15.3'),
'connecting': ''.join(circle(x,12,.7,fill='currentColor',stroke='none') for x in (9,12,15)),
'unlock': circle(10,10.5,1.8)+path('M11.4 11.9 L15.4 15.9 M13.5 14 L15 12.5'),
'check': path('M7.8 11.8 L10.8 14.7 L16.2 9.3'),
'reconnect': path('M15.6 10.2 A4 4 0 1 0 15.6 14.1 M15.6 7.3 V10.2 H12.7'),
'network': path('M10 10 H9 A2 2 0 0 0 9 14 H10 M14 10 H15 A2 2 0 0 1 15 14 H14'),
'error': path('M9 9 L15 15 M15 9 L9 15'),
'setup': path('M12 7.8 V12.3')+circle(12,15.5,.65,fill='currentColor',stroke='none'),
'unknown': path('M9.7 9.2 C9.8 6.7 14.6 6.7 14.6 9.4 C14.6 11.3 12 11.3 12 13')+circle(12,15.9,.65,fill='currentColor',stroke='none'),
'expired': path('M9 8 H15 M9 16 H15 M9.7 8 C9.7 10.3 14.3 13.7 14.3 16 M14.3 8 C14.3 10.3 9.7 13.7 9.7 16'),
'no-time': path('M9 12 H15'),
'clock': path('M12 7.5 V12 L15 13.7'),
}
ACTIONS = {
'connect': path('M8 3 V8 M16 3 V8 M6 8 H18 V11 C18 14.3 15.3 17 12 17 C8.7 17 6 14.3 6 11 Z M12 17 V21'),
'disconnect': path('M8 2.5 V6 M16 2.5 V6 M6 6 H18 V8 C18 10.2 15.3 11.5 12 11.5 C8.7 11.5 6 10.2 6 8 Z M7.5 15.5 Q12 19.5 16.5 15.5 M12 17.5 V21.5'),
'cancel': path('M6 6 L18 18 M18 6 L6 18'),
'import': path('M5 13 V20 H19 V13 M12 3 V15 M8 11 L12 15 L16 11'),
'password': circle(8,8,4)+path('M11 11 L20 20 M15 15 L17 13 M18 18 L20 16'),
'copy': path('M9 4 H19 V15 M5 8 H15 V21 H5 Z'),
'logs': path('M6 3 H14 L19 8 V21 H6 Z M14 3 V8 H19 M9 12 H16 M9 16 H16'),
'refresh': path('M19 9 A7.2 7.2 0 0 0 6.8 6.5 L4 9 M4 4 V9 H9 M5 15 A7.2 7.2 0 0 0 17.2 17.5 L20 15 M20 20 V15 H15'),
'quit': path('M11 3 H4 V21 H11 M10 12 H21 M17 8 L21 12 L17 16'),
'manual': '<rect x="3" y="6" width="18" height="12" rx="2"/>'+path('M6.5 10 H7 M11.5 10 H12 M16.5 10 H17 M7 14 H17'),
'auto': path('M13 3 L6 13 H11 L10 21 L18 10 H13 Z'),
'local': '<rect x="5" y="4" width="14" height="12" rx="1.5"/>'+path('M2.5 19 H21.5 L20 21 H4 Z'),
'vault': '<rect x="4.5" y="3" width="15" height="18" rx="2.5"/>'+circle(12,12,3.8)+path('M12 8.2 V10 M15.8 12 H14 M12 15.8 V14 M8.2 12 H10'),
}
ENTRIES=[]
def add(key,label,section,kind,mark='',tone='ink',progress=None):
ENTRIES.append(dict(key=key,label=label,section=section,kind=kind,mark=mark,tone=tone,progress=progress))
for args in [
('vpn-off','Отключён','off','ink'),('vpn-connecting','Подключение','connecting','ink'),
('vpn-unlock','Ожидаем вход','unlock','ink'),('vpn-connected','Подключён','check','green'),
('vpn-reconnect','Переподключение','reconnect','ink'),('vpn-network','Проблема сети','network','amber'),
('vpn-error','Ошибка','error','red'),('vpn-setup','Нужна настройка','setup','amber'),
('vpn-unknown','Статус неизвестен','unknown','ink')]:
add(args[0],args[1],'Состояния VPN','shield',args[2],args[3])
for key,label,progress,tone in [
('time-start','Начало',0,'green'),('time-quarter','25% прошло',.25,'green'),
('time-half','50% прошло',.5,'green'),('time-three-quarter','75% прошло',.75,'green'),
('time-hour','Остался час',.875,'amber'),('time-quarter-hour','Осталось 15 мин',.96875,'red'),
('time-expired','Срок истёк',1,'red')]:
add(key,label,'Время сессии','progress','expired' if progress==1 else '',tone,progress)
add('time-unknown','Срок неизвестен','Время сессии','shield','no-time')
add('format-shield','Щит с прогрессом','Форматы времени','progress','', 'green',.58)
add('format-clock','Щит-часы','Форматы времени','shield','clock','green')
add('format-time','Щит + время','Форматы времени','digital','check','green')
add('format-ring','Кольцо','Форматы времени','ring','', 'green',.58)
for key,label in [('connect','Подключить'),('disconnect','Отключить'),('cancel','Отменить'),('import','Импорт ключа'),('password','Логин и пароль'),('copy','Копировать IP'),('logs','Логи'),('refresh','Обновить статус'),('quit','Выход')]:
add('action-'+key,label,'Действия меню','action',key)
for key,label in [('manual','Вручную'),('auto','Автоматически'),('local','Ключ на Mac'),('vault','Bitwarden')]:
add('source-'+key,label,'Источники и режимы','action',key)
def content(entry,palette):
ink=palette['ink'];accent=palette[entry['tone']]
if entry['kind']=='action': return ACTIONS[entry['mark']]
if entry['kind']=='ring':
body=progress_interior(lambda **attrs:circle(12,12,9,**attrs),entry['progress'],21,18,palette,accent)
return body+circle(12,12,9,stroke=palette['muted'],stroke_opacity='.65')+circle(12,12,9,stroke=accent,stroke_dasharray=f'{entry["progress"]*math.tau*9:.5f} {math.tau*9:.5f}',transform='rotate(-90 12 12)',stroke_linecap='butt')
shape=path(SHIELD,stroke=ink)
if entry['kind']=='progress':
shape=progress_interior(lambda **attrs:path(SHIELD,**attrs),entry['progress'],22,20,palette,accent)
shape+=path(SHIELD,stroke=palette['muted'],stroke_opacity='.68')
if entry['progress']:
shape+=path(SHIELD,stroke=accent,stroke_dasharray=f'{entry["progress"]*SHIELD_LENGTH:.5f} {SHIELD_LENGTH:.5f}',stroke_linecap='butt')
if entry['mark']:
mark_color=ink if entry['kind']=='progress' else accent
shape+=f'<g stroke="{mark_color}" color="{mark_color}">{MARKS[entry["mark"]]}</g>'
if entry['kind']=='digital':
shape+='<text x="29" y="17.2" stroke="none" fill="'+ink+'" font-family="Menlo,monospace" font-size="16.5" font-weight="500">2:18</text>'
return shape
def svg(entry,theme):
p=PALETTES[theme];width=74 if entry['kind']=='digital' else 24
return f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="24" viewBox="0 0 {width} 24" fill="none" stroke="{p["ink"]}" color="{p["ink"]}" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>{html.escape(entry["label"])}</title>'+content(entry,p)+'</svg>'
def text(x,y,value,size=14,color='#657381',weight=400):
return f'<text x="{x}" y="{y}" fill="{color}" font-family="Helvetica Neue,Arial,sans-serif" font-size="{size}" font-weight="{weight}">{html.escape(value)}</text>'
def sheet(theme):
p=PALETTES[theme];width=1120;elements=[]
elements.append(text(42,53,'Lemana VPN',27,p['ink'],600))
elements.append(text(42,82,'Единая семья значков',16,p['muted']))
elements.append(text(824,53,'Щит / 16 pt',14,p['ink'],500))
elements.append(text(824,78,'Контур 1,75 · сетка 24',12,p['muted']))
y=130
for section in dict.fromkeys(e['section'] for e in ENTRIES):
elements.append(text(42,y,section,16,p['ink'],500));y+=24
subset=[e for e in ENTRIES if e['section']==section]
for index,e in enumerate(subset):
col=index%6;row=index//6;x=42+col*174;top=y+row*119
logical_width=74 if e['kind']=='digital' else 24
large_x=x+(160-logical_width*1.5)/2
small_x=x+(160-logical_width*2/3)/2
elements.append(f'<g transform="translate({large_x} {top+4}) scale(1.5)" fill="none" stroke="{p["ink"]}" color="{p["ink"]}" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">'+content(e,p)+'</g>')
elements.append(f'<g transform="translate({small_x} {top+48}) scale(.6666667)" fill="none" stroke="{p["ink"]}" color="{p["ink"]}" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">'+content(e,p)+'</g>')
elements.append(text(x+7,top+91,e['label'],12,p['ink']))
y+=((len(subset)+5)//6)*119+14
elements.append(f'<path d="M42 {y-17} H1078" stroke="{p["line"]}" stroke-width="1"/>')
elements.append(text(42,y+10,'Увеличенный вид + 16 pt. Цвет дополнен формой и подписью.',12,p['muted']))
elements.append(text(42,y+32,'Пример сессии: 8 ч. Контур и внутренняя заливка растут к её концу. Набор пока не установлен.',12,p['muted']))
height=y+65
return f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}"><rect width="{width}" height="{height}" fill="{p["bg"]}"/>'+''.join(elements)+'</svg>'
for theme in PALETTES:
for e in ENTRIES:
source=ROOT/'svg'/f'{e["key"]}-{theme}.svg'
source.write_text(svg(e,theme))
subprocess.run(['rsvg-convert',str(source),'-o',str(ROOT/'png'/f'{e["key"]}-{theme}.png'),'-h','96'],check=True)
source=ROOT/f'icon-sheet-{theme}.svg';source.write_text(sheet(theme))
subprocess.run(['rsvg-convert',str(source),'-o',str(ROOT/f'icon-sheet-{theme}.png'),'-w','2240'],check=True)
(ROOT/'manifest.json').write_text(json.dumps(ENTRIES,ensure_ascii=False,indent=2)+'\n')
with zipfile.ZipFile(ROOT/'lemana-vpn-icons-v1.zip','w',zipfile.ZIP_DEFLATED) as archive:
for file in sorted((ROOT/'svg').glob('*.svg')):
archive.write(file,file.relative_to(ROOT))
archive.write(ROOT/'manifest.json','manifest.json')
print(f'{len(ENTRIES)} icon entries, two themes, SVG assets and preview sheets created.')