221 lines
8.5 KiB
Python
221 lines
8.5 KiB
Python
#!/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|ny-peek|nyp-|::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}')
|