#!/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}')