Files
mama-box/tools/gen-night.py

221 lines
8.5 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
"""夜晚模式样式生成器:读 mama-box.css,把浅色底 / 深色字自动翻成夜色,写进 night.css 的「自动生成」段。
为什么用生成而不是手写:mama-box.css 有 4700 行、几百处写死的 #fff / 暖白,手写一份夜间版
既漏得多、又会和日间版各改各的。这里只做「颜色翻转」这一件机械的事:
· 背景 / 边框类属性里,亮且不太艳的颜色(暖白、米色、浅灰线)→ 同色相的深色(卡片比页面底略亮);
· color / fill / stroke 里,暗的颜色(正文墨色、灰字)→ 同色相的浅色;
· 品牌橙、工具卡的绿 / 紫 / 蓝这些高饱和色、白字、透明度低的玻璃白,一律不动。
每条规则原样复制选择器、前面加 html.night,所以优先级比日间版恰好高一级,顺序关系不变。
自动翻不好的地方写在 night.css 的「手调」段(在生成段之后,同优先级后写的赢)。
用法:python3 tools/gen-night.py [mama-box.css] 改了 mama-box.css 的颜色后重跑一次。
"""
import re, sys, colorsys, pathlib
ROOT = pathlib.Path(__file__).resolve().parent.parent
SRC = pathlib.Path(sys.argv[1]) if len(sys.argv) > 1 else ROOT / 'mama-box.css'
OUT = ROOT / 'night.css'
MARK_A = '/* ==== 自动生成段:tools/gen-night.py 写入,别手改 ==== */'
MARK_B = '/* ==== 自动生成段结束 ==== */'
BG_PROPS = {'background', 'background-color', 'background-image', 'border', 'border-color', 'border-top',
'border-bottom', 'border-left', 'border-right', 'border-top-color', 'border-bottom-color',
'border-left-color', 'border-right-color', 'outline', 'outline-color', 'box-shadow', 'caret-color'}
FG_PROPS = {'color', 'fill', 'stroke', '-webkit-text-fill-color', 'text-decoration-color'}
SKIP_SEL = re.compile(r'home-hero|hero-|\.hi-|\.hd-|\.letter-card|zd-|ar-hero|page-hero|::selection|\.skip')
COL = re.compile(r'#([0-9a-fA-F]{3,8})\b|rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*(?:,\s*([\d.]+%?)\s*)?\)')
def parse_hex(h):
if len(h) in (3, 4):
h = ''.join(c * 2 for c in h)
r, g, b = (int(h[i:i + 2], 16) / 255 for i in (0, 2, 4))
a = int(h[6:8], 16) / 255 if len(h) == 8 else 1
return r, g, b, a
def fmt(r, g, b, a):
R, G, B = (round(max(0, min(1, x)) * 255) for x in (r, g, b))
return f'#{R:02X}{G:02X}{B:02X}' if a >= .999 else f'rgba({R},{G},{B},{round(a, 3)})'
NIGHT_H = 280 / 360
def bg_map(r, g, b, a):
h, l, s = colorsys.rgb_to_hls(r, g, b)
if l < .80: # 艳色 / 中间调:品牌色、工具卡底,不动(浅粉浅黄这类高饱和的淡色照样翻)
return None
if a < .2: # 几乎透明的白:只是渐变收尾,不动
return None
nl = max(.115, .17 - (1 - l) * .5) # #fff→.17(卡片) 米色浅底 .93→.135 浅线→.12;页底另由 --bg 定
if s < .3: # 近中性的暖白 / 灰:统一染成夜色的一点紫,不然一片死灰
h, ns = NIGHT_H, .10
else:
ns = min(s, .5) * .45
return fmt(*colorsys.hls_to_rgb(h, nl, ns), a)
def fg_map(r, g, b, a):
h, l, s = colorsys.rgb_to_hls(r, g, b)
if l > .55 or (s > .55 and l > .3): # 已经够亮 / 艳色强调字,不动
return None
nl = min(.93, .95 - l * .55) # 墨色 .15→.87 灰字 .42→.72
ns = min(s, .3) * .5
if s < .3:
h, ns = 25 / 360, .12 # 字色带一点暖,跟日间的暖墨色同一气质
return fmt(*colorsys.hls_to_rgb(h, nl, ns), a)
def vivid(val):
"""这条背景里有没有「主色」(品牌橙、工具卡的绿紫蓝):有就整条不动,否则渐变里的白高光被翻黑、主色还在,成了脏灰。"""
for m in COL.finditer(val):
if m.group(1):
if len(m.group(1)) not in (3, 4, 6, 8):
continue
r, g, b, a = parse_hex(m.group(1))
else:
r, g, b = (float(m.group(i)) / 255 for i in (2, 3, 4))
a = m.group(5); a = 1 if a is None else (float(a[:-1]) / 100 if a.endswith('%') else float(a))
h, l, s = colorsys.rgb_to_hls(r, g, b)
if a > .5 and s > .45 and .3 < l < .78:
return True
return False
def recolor(val, fn):
changed = False
def sub(m):
nonlocal changed
if m.group(1):
if len(m.group(1)) not in (3, 4, 6, 8):
return m.group(0)
c = parse_hex(m.group(1))
else:
a = m.group(5)
a = 1 if a is None else (float(a[:-1]) / 100 if a.endswith('%') else float(a))
c = (float(m.group(2)) / 255, float(m.group(3)) / 255, float(m.group(4)) / 255, a)
n = fn(*c)
if n is None:
return m.group(0)
changed = True
return n
out = COL.sub(sub, val)
return out if changed else None
def map_keyword(prop, val):
v = val.strip().lower().replace('!important', '').strip()
if prop in BG_PROPS and v in ('white',):
return val.lower().replace('white', '#2A2530')
return None
def strip_comments(s):
return re.sub(r'/\*.*?\*/', '', s, flags=re.S)
def split_decls(body):
out, depth, cur = [], 0, ''
for ch in body:
if ch == '(':
depth += 1
elif ch == ')':
depth -= 1
if ch == ';' and depth == 0:
out.append(cur); cur = ''
else:
cur += ch
out.append(cur)
return [d.strip() for d in out if d.strip()]
def prefix_sel(sel):
parts, depth, cur = [], 0, ''
for ch in sel:
if ch == '(':
depth += 1
elif ch == ')':
depth -= 1
if ch == ',' and depth == 0:
parts.append(cur); cur = ''
else:
cur += ch
parts.append(cur)
res = []
for p in parts:
p = p.strip()
if p.startswith(':root'):
continue
if re.match(r'html\b', p):
p = 'html.night' + p[4:]
else:
p = 'html.night ' + p
res.append(p)
return ','.join(res)
def walk(css):
"""返回 [(prelude 或 None, 内容)]。嵌套 @media / @supports 递归,@keyframes / @font-face 跳过。"""
i, n, out = 0, len(css), []
while i < n:
j = css.find('{', i)
if j < 0:
break
pre = css[i:j].strip()
depth, k = 1, j + 1
while k < n and depth:
if css[k] == '{':
depth += 1
elif css[k] == '}':
depth -= 1
k += 1
body = css[j + 1:k - 1]
i = k
if pre.startswith('@'):
if re.match(r'@(media|supports)', pre):
inner = walk(body)
if inner:
out.append(pre + '{\n' + '\n'.join(inner) + '\n}')
continue
# ⚠️ 所有规则的**所有**颜色声明(含没翻的)都抄一份:
# 日间版里后写的规则会盖掉先写的;只抄翻过色的那几条,先写的规则加了 html.night 反而赢过后写的(踩过:工具卡变灰)。
# 同理,头图那些不翻色的规则也照抄,免得被前面翻过色的通用规则压住。
skip = bool(SKIP_SEL.search(pre))
changed = skip
decls = []
for d in split_decls(body):
if ':' not in d or d.startswith('--'):
continue
prop, val = d.split(':', 1)
prop = prop.strip().lower()
if prop not in BG_PROPS and prop not in FG_PROPS:
continue
nv = None
if not skip and prop in BG_PROPS and not vivid(val):
nv = recolor(val, bg_map) or map_keyword(prop, val)
elif not skip and prop in FG_PROPS:
nv = recolor(val, fg_map)
if nv:
changed = True
decls.append(f'{prop}:{(nv or val).strip()}')
sel = prefix_sel(pre)
if decls and sel: # 没翻色的也抄:它可能是在覆盖前面某条翻过色的规则(工具卡彩色底就是这么丢的)
out.append(sel + '{' + ';'.join(decls) + '}')
return out
rules = walk(strip_comments(SRC.read_text()))
gen = MARK_A + '\n' + '\n'.join(rules) + '\n' + MARK_B
old = OUT.read_text() if OUT.exists() else ''
if MARK_A in old:
a, b = old.index(MARK_A), old.index(MARK_B) + len(MARK_B)
new = old[:a] + gen + old[b:]
else:
new = old + '\n' + gen + '\n'
OUT.write_text(new)
print(f'{len(rules)} 条规则 → {OUT}')