Files
OneCAD/assets/icons/ui/design/tools/icon_palette.py
T

289 lines
14 KiB
Python

"""Shared semantic colour policy for repository icon tools; values live in palette.toml."""
import copy
import hashlib
import os
import re
import tomllib
import uuid
import xml.etree.ElementTree as ET
VISIONS = ('off', 'red-green', 'blue-yellow')
def validation_errors(palette, ids):
errors, assigned = [], set()
accents = palette.get('accents', {})
for accent, icons in palette.get('icon-accents', {}).items():
if accent not in accents:
errors.append('unknown semantic accent ' + accent)
for icon in icons:
if icon not in ids or icon in assigned:
errors.append('unknown or repeated semantic icon ' + icon)
assigned.add(icon)
groups = [('', accents)] + list(palette.get('variants', {}).items())
for vision, group in groups:
if vision and vision not in VISIONS:
errors.append('unknown colour-vision group ' + vision)
for accent, roles in group.items():
if accent not in accents:
errors.append('variant has unknown accent ' + accent)
for role, value in roles.items():
if role == 'guide':
errors.append('Guides use the shared guide role, never an accent override')
if role not in palette['roles']:
errors.append('unknown colour role ' + role)
if 'use' in value:
value = roles.get(value['use'], {})
for theme in ('light', 'dark'):
if not re.fullmatch(r'#[0-9a-fA-F]{6}', value.get(theme, '')):
errors.append(f'{vision}/{accent}/{role}: invalid {theme} hex or role link')
return errors
def default_accent(palette, icon):
return next((accent for accent, ids in palette.get('icon-accents', {}).items() if icon in ids), '')
def resolve(palette, aliases, icon):
"""Explicit alias accents win; defaults belong to an id, not its shared master."""
alias = aliases.get(icon, icon)
if isinstance(alias, dict):
return alias['master'], alias.get('accent') or default_accent(palette, icon)
return alias, default_accent(palette, icon)
def role_values(palette, theme, accent='', vision='off'):
values = {role: value[theme] for role, value in palette['roles'].items()}
overrides = palette.get('accents', {}).get(accent, {})
for role, value in overrides.items():
if role == 'guide': continue
if 'use' in value:
value = overrides[value['use']]
values[role] = value[theme]
for role, value in palette.get('variants', {}).get(vision, {}).get(accent, {}).items():
if role == 'guide': continue
values[role] = value[theme]
return values
def colour_ref(palette, theme, accent, role, vision='off'):
"""A live-review variable for the exact token, including links and overrides."""
path = 'roles.' + role
group = palette.get('accents', {}).get(accent, {})
if role != 'guide' and role in group:
target = group[role].get('use', role)
path = 'accents.' + accent + '.' + target
if role != 'guide' and role in palette.get('variants', {}).get(vision, {}).get(accent, {}):
path = 'variants.' + vision + '.' + accent + '.' + role
value = role_values(palette, theme, accent, vision)[role]
return 'var(' + variable(path, theme) + ',' + value + ')'
def variable(path, theme):
return '--icon-palette-' + path.replace('.', '-') + '-' + theme
def tokens(palette):
"""Editable display paints only; stable SVG source identifiers are never exposed."""
purposes = {'ink': 'Outline', 'line': 'Line art', 'passive-1': 'Top face',
'passive-2': 'Left face', 'passive-3': 'Right face', 'gap': 'Knockout',
'guide': 'Guides', 'active-1': 'Pale', 'active-2': 'Mid', 'active-3': 'Stroke'}
result = []
groups = [('roles', '', palette['roles'])]
groups += [('accents.' + name, 'Constr.' if name == 'construction' else name.title(), roles) for name, roles in palette.get('accents', {}).items()]
groups += [('variants.' + vision + '.' + accent,
accent.title() + (' R/G' if vision == 'red-green' else ' B/Y'), roles)
for vision, accents in palette.get('variants', {}).items() for accent, roles in accents.items()]
for prefix, group, roles in groups:
for role, value in roles.items():
linked = prefix + '.' + value['use'] if 'use' in value else ''
paints = roles[value['use']] if linked else value
purpose = purposes.get(role, role)
if not group and role.startswith('active-'): purpose = 'Blue ' + purpose.lower()
result.append({'id': prefix + '.' + role, 'name': (group + ' ' + purpose).strip(),
'light': paints['light'], 'dark': paints['dark'], 'linked': linked,
'knockout': prefix == 'roles' and role == 'gap'})
return result
def palette_css(palette):
return ':root{' + ''.join(variable(token['id'], theme) + ':' + token[theme] + ';'
for token in tokens(palette) if not token['linked'] and not token['knockout']
for theme in ('light', 'dark')) + '}'
def revision(path):
with open(path, 'rb') as source: return hashlib.sha256(source.read()).hexdigest()
def save_colours(path, base_revision, changes):
"""Apply validated display colours atomically, preserving comments and source ids."""
with open(path, encoding='utf-8') as source: text = source.read()
if hashlib.sha256(text.encode()).hexdigest() != base_revision:
raise ValueError('Palette changed on disk. Reload before applying your colours.')
palette = tomllib.loads(text)
editable = {token['id'] for token in tokens(palette) if not token['linked'] and not token['knockout']}
if not isinstance(changes, dict) or not changes or not set(changes) <= editable:
raise ValueError('Unknown, linked or transparent palette token')
expected = copy.deepcopy(palette)
for key, paints in changes.items():
if not isinstance(paints, dict) or set(paints) != {'light', 'dark'} or any(
not isinstance(value, str) or not re.fullmatch(r'#[0-9a-fA-F]{6}', value) for value in paints.values()):
raise ValueError('Use six-digit hex colours for both themes')
target = expected
for part in key.split('.'): target = target[part]
target.update({theme: colour.lower() for theme, colour in paints.items()})
section = re.compile(r'(?m)^\[' + re.escape(key) + r'\][^\n]*\n(?:(?!^\[)[\s\S])*')
match = section.search(text)
if not match: raise ValueError('Missing palette section ' + key)
block = match[0]
for theme, colour in paints.items():
block, count = re.subn(r'(?m)^(\s*' + theme + r'\s*=\s*)["\']#[0-9a-fA-F]{6}["\']',
lambda m: m[1] + '"' + colour.lower() + '"', block)
if count != 1: raise ValueError('Missing colour in ' + key)
text = text[:match.start()] + block + text[match.end():]
if tomllib.loads(text) != expected: raise ValueError('Palette update changed unrelated data')
temporary = str(path) + '.' + uuid.uuid4().hex + '.tmp'
try:
with open(temporary, 'w', encoding='utf-8') as output: output.write(text)
os.replace(temporary, path)
finally:
if os.path.exists(temporary): os.unlink(temporary)
def transparent_knockouts(svg, source):
"""GAP erases preceding artwork; later foreground paints remain untouched.
Masks are generated only for display. The accepted SVG remains the editable
authority, with GAP as its stable source identifier. Literal mask white/black
are coverage values, not palette roles.
"""
if source.lower() not in svg.lower(): return svg
ns = 'http://www.w3.org/2000/svg'
ET.register_namespace('', ns)
root = ET.fromstring(svg)
tag = lambda node: node.tag.rsplit('}', 1)[-1]
element = lambda name, attrs: ET.Element('{' + ns + '}' + name, attrs)
definitions, metadata, output = [], [], []
used = {node.get('id') for node in root.iter() if node.get('id')}
serial = 0
def wrap(node, ancestors):
for attrs in reversed(ancestors):
group = element('g', attrs)
group.append(node)
node = group
return node
def erase(node, ancestors):
nonlocal serial, output
if not output: return
serial += 1
while 'sc-knockout-' + str(serial) in used: serial += 1
name = 'sc-knockout-' + str(serial)
used.add(name)
mask = element('mask', {'id': name, 'maskUnits': 'userSpaceOnUse',
'maskContentUnits': 'userSpaceOnUse', 'x': '0', 'y': '0',
'width': '64', 'height': '64', 'mask-type': 'luminance'})
mask.append(element('path', {'d': 'M0 0H64V64H0Z', 'fill': 'white'}))
mask.append(wrap(node, ancestors))
definitions.append(mask)
group = element('g', {'mask': 'url(#' + name + ')'})
group.extend(output)
output = [group]
def visit(node, ancestors=()):
kind = tag(node)
if kind == 'defs':
definitions.extend(copy.deepcopy(list(node)))
elif kind == 'title':
metadata.append(copy.deepcopy(node))
elif kind == 'g':
attrs = {key: value for key, value in node.attrib.items() if key != 'id'}
for child in node: visit(child, (*ancestors, attrs))
else:
fill_cut = node.get('fill', '').lower() == source.lower()
stroke_cut = node.get('stroke', '').lower() == source.lower()
if not fill_cut and not stroke_cut:
output.append(wrap(copy.deepcopy(node), ancestors))
return
# SVG paints fill before stroke. Split only affected shapes so a
# normal stroke is drawn after its cutout fill (and vice versa).
for part, cut in [('fill', fill_cut), ('stroke', stroke_cut)]:
paint = node.get(part, 'none')
if paint == 'none': continue
shape = copy.deepcopy(node)
shape.attrib.pop('id', None)
shape.set('stroke' if part == 'fill' else 'fill', 'none')
if cut:
shape.set(part, 'black')
erase(shape, ancestors)
else:
output.append(wrap(shape, ancestors))
for child in root: visit(child)
root[:] = []
if definitions:
defs = element('defs', {})
defs.extend(definitions)
root.append(defs)
root.extend(metadata)
root.extend(output)
return ET.tostring(root, encoding='unicode')
def themed(svg, palette, theme, accent='', vision='off', live=False, namespace=''):
gap = palette['roles'].get('gap')
if gap: svg = transparent_knockouts(svg, gap.get('source', gap['light']))
values = role_values(palette, theme, accent, vision)
if live: values = {role: colour_ref(palette, theme, accent, role, vision) for role in values}
# Only non-blue dashed references become guides. Original blue action paints
# keep their semantic accent. Knockout mask coverage stays literal black.
preserved = {value.get('source', value['light']).lower() for role, value in palette['roles'].items()
if role == 'gap' or role.startswith('active-')}
# Resolve this paint after hex substitution, so a guide colour that happens
# to equal another source hex cannot be remapped a second time.
dashed = {}
def protect_dash(match):
tag = match[0]
if 'stroke-dasharray=' not in tag: return tag
def substitute(paint):
if paint[2].lower() in preserved: return paint[0]
key = '__GUIDE_PAINT_' + str(len(dashed)) + '__'; dashed[key] = values['guide']
return 'stroke="' + key + '"'
return re.sub(r'stroke=(["\'])(#[0-9a-fA-F]{6}|currentColor)\1', substitute, tag)
svg = re.sub(r'<(?:path|circle)\b[^>]*>', protect_dash, svg)
paints = {value.get('source', value['light']).lower(): values[role] for role, value in palette['roles'].items() if role != 'line'}
svg = re.sub(r'#[0-9a-fA-F]{6}', lambda match: paints.get(match[0].lower(), match[0]), svg)
svg = svg.replace('currentColor', values['line'])
for key, value in dashed.items(): svg = svg.replace(key, value)
if namespace:
# Inline SVG ids share the page's global id namespace, including hidden
# theme/vision/size variants and aliases of the same master.
namespace = re.sub(r'[^a-zA-Z0-9_-]', '-', namespace)
ids = set(re.findall(r'\bid=["\']([^"\']+)["\']', svg))
for name in ids:
scoped = namespace + '-' + name
svg = re.sub(r'\bid=(["\'])' + re.escape(name) + r'\1', lambda m: 'id="' + scoped + '"', svg)
svg = svg.replace('url(#' + name + ')', 'url(#' + scoped + ')')
return svg
def review_css(palette, live=False):
"""Review controls follow the same shared colours; artwork is rendered by themed()."""
rules = []
for dark in (False, True):
theme = 'dark' if dark else 'light'
for vision in VISIONS:
selector = 'body' + ('.dark' if dark else ':not(.dark)') + '[data-vision="' + vision + '"]'
primary = lambda accent='': colour_ref(palette, theme, accent, 'active-3' if 'active-3' in palette['roles'] else 'line', vision) if live else role_values(palette, theme, accent, vision).get('active-3', palette['roles']['line'][theme])
values = {'accent': primary(),
'accent-ink': colour_ref(palette, 'dark', '', 'line', vision) if live else palette['roles']['line']['dark'],
'success': primary('sketch'),
'warning': primary('mesh'),
'error': primary('constraint')}
rules.append(selector + '{' + ''.join('--review-' + key + ':' + value + ';' for key, value in values.items()) + '}')
for accent in palette.get('accents', {}):
rules.append(selector + ' .card[data-accent="' + accent + '"]{--icon-accent:' + primary(accent) + '}')
return '\n'.join(rules)