Files
tamabox/dev/theme-tools/gen_new_sakura.py
T
tamakyi 5c0de805c6 new-sakura:花枝朝向与分叉修正,素材包一键导出上传到对象存储
- gen_new_sakura.py:花朵加各自旋转角(原来一律朝上,像贴纸);分叉从垂直下垂改成向右上斜;
  新增 --asset-base / --out —— 填前缀就引用对象存储位图,留空仍是内联 SVG。
- export_assets.py(新增):从 gen_new_sakura 的同一份 SVG 出图(直接 import,不另画一套),
  用无头 Chrome 透明底渲染后转 WebP;--upload 一次传 CSS + 5 张 webp + 生成的 INSTALL.txt,
  公开地址按 data/r2.txt 的 image_bucket_cdn_host(与 app.ini 同名)拼。
- serve.upload_files 支持逐文件 cacheControl。原来硬编码 immutable,而 INSTALL.txt 与镜像
  CSS 的键不带版本 —— 重新生成后浏览器/CDN 一年内都还是旧内容。现在位图 1 天、CSS 5 分钟、
  README 60 秒;selftest 补一条覆盖断言。
- 文档更正两处事实:①自定义域 r2.337845818.best 实测可匿名读(S3 endpoint 仍 403);
  ②服务端有 gzip,内联版 51KB 实际传输 3.9KB,比位图版(2.7KB + 5 图 48.9KB)小得多,
  所以「换位图省 CSS 体积」不成立,位图只在素材要复用到非 CSS 场景时才有意义。
  另记:曾以 immutable 传过的键必须去控制台清缓存,重传不会让边缘失效。
2026-10-22 11:06:13 +08:00

401 lines
17 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.
"""生成 new-sakura 主题的 CSS。
为什么用脚本生成而不用生成器的图层功能:生成器的装饰层只认「图片素材」(导出时
打进 zip 或传对象存储),而这里的花瓣是**内联 SVG 的 data URI** —— 零外部请求、
任意缩放不糊、改配色只改一个色值。代价是 CSS 里会出现一长串百分号编码的 URI,
所以源 SVG 保留在本文件里,改完重跑:
python dev/theme-tools/gen_new_sakura.py
输出:static/css/themes/new-sakura.css
"""
import os
import urllib.parse
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
OUT = os.path.join(ROOT, "static", "css", "themes", "new-sakura.css")
def uri(svg):
"""转成 CSS url()。safe='' 是刻意的:连逗号、分号、括号都编码掉 ——
`--tb-deco` 是按顶层逗号切分的(internal/theme 的 TestDecoSlotCount),
URI 里留一个裸逗号就会被当成多一层的装饰。"""
return 'url("data:image/svg+xml,' + urllib.parse.quote(svg, safe="") + '")'
# ---- 花与瓣 ----------------------------------------------------------------
# 五瓣樱花:五个椭圆绕中心旋 72°,中心一团深一点的蕊。不做花瓣缺口——
# 在 30~90px 的显示尺寸上缺口只剩 2px,反而像毛边;靠五瓣轮廓就够识别。
def blossom(light, deep, heart):
petals = "".join(
f'<ellipse cx="50" cy="27" rx="14" ry="21" transform="rotate({a} 50 50)"/>'
for a in (0, 72, 144, 216, 288)
)
return (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">'
f'<g fill="{light}">{petals}</g>'
f'<circle cx="50" cy="50" r="7.5" fill="{deep}"/>'
f'<circle cx="50" cy="50" r="3" fill="{heart}"/>'
"</svg>"
)
# 单瓣:顶端留一个 V 形缺口(樱花花瓣的标志),比整朵花更适合当"飘落"的单位。
def petal(fill):
return (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 56">'
f'<path d="M20 1c9 0 15 8 15 18 0 5-2 9-4 12l-4-5-7 9-7-9-4 5c-2-3-4-7-4-12C5 9 11 1 20 1z" fill="{fill}"/>'
"</svg>"
)
# 枝条:一根主枝 + 两根小杈 + 五朵花 + 三片落瓣。
# 花是重复内联而不是 <use href="#f">:data URI 里的片段引用在各浏览器上
# 行为不一致(Safari 曾整朵不渲染),多几百字节换确定性。
def branch(wood, light, deep, heart, flip=False):
fl = ' transform="translate(420,0) scale(-1,1)"' if flip else ""
# 花要比枝小得多、数量多几朵。早先 5 朵 scale≈1 的花配一根细枝,缩到 300px
# 显示时是一串糖葫芦;真实樱花枝是"小碎花沿枝密布",所以花收到 0.42~0.62。
# 第四项是旋转角:一朵朵都朝正上方会像贴纸糊上去的,真实花枝上朝向各异
blossoms = [
(302, 74, 0.62, 14), (256, 110, 0.55, -22), (214, 138, 0.60, 34), (172, 176, 0.58, -6),
(352, 42, 0.50, 48), (140, 214, 0.46, 20), (116, 246, 0.40, -32), (326, 128, 0.42, 28),
]
petals = [(272, 158, 22), (318, 150, -18), (196, 92, 40), (150, 196, -26)]
body = []
for x, y, s, rot in blossoms:
body.append(
f'<g transform="translate({x} {y}) rotate({rot}) scale({s}) translate(-50 -50)">'
'<g fill="%s">' % light
+ "".join(f'<ellipse cx="50" cy="27" rx="14" ry="21" transform="rotate({a} 50 50)"/>'
for a in (0, 72, 144, 216, 288))
+ f'</g><circle cx="50" cy="50" r="7" fill="{deep}"/>'
f'<circle cx="50" cy="50" r="2.8" fill="{heart}"/></g>'
)
for x, y, rot in petals:
body.append(
f'<g transform="translate({x} {y}) rotate({rot}) scale(0.42) translate(-20 -28)">'
f'<path d="M20 1c9 0 15 8 15 18 0 5-2 9-4 12l-4-5-7 9-7-9-4 5c-2-3-4-7-4-12C5 9 11 1 20 1z" fill="{light}"/></g>'
)
return (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 420 320">'
f'<g{fl}>'
f'<path d="M2 318C74 276 112 212 176 184c62-28 102-72 146-118 26-28 52-46 82-58" '
f'fill="none" stroke="{wood}" stroke-width="4.2" stroke-linecap="round" opacity=".62"/>'
f'<path d="M176 184c-4-30 4-56 16-78M262 126c16-28 32-48 46-58" '
f'fill="none" stroke="{wood}" stroke-width="2.8" stroke-linecap="round" opacity=".5"/>'
+ "".join(body) + "</g></svg>"
)
# 平铺底纹:极淡的小花 + 落瓣。
# 密度是刻意的:最早一版放了 2 朵花 + 2 片瓣并且 260px 平铺,页面上读出来是
# 「整齐的包装纸」——因为每个元素都在固定格点上重复。现在只留 1 朵 + 2 片,
# 尺寸放大到 300px 且各自错位,平铺时才看不出周期。
def pattern(light):
one = (
'<g fill="%s">' % light
+ "".join(f'<ellipse cx="50" cy="27" rx="14" ry="21" transform="rotate({a} 50 50)"/>'
for a in (0, 72, 144, 216, 288))
+ "</g>"
)
petal_path = ('<path d="M20 1c9 0 15 8 15 18 0 5-2 9-4 12l-4-5-7 9-7-9-4 5'
'c-2-3-4-7-4-12C5 9 11 1 20 1z" fill="%s"/>')
return (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300">'
f'<g transform="translate(96 84) scale(0.34) translate(-50 -50)" opacity="0.34">{one}</g>'
f'<g transform="translate(236 208) rotate(28) scale(0.26)" opacity="0.24">'
+ (petal_path % light) +
"</g>"
f'<g transform="translate(188 52) rotate(-34) scale(0.2)" opacity="0.18">'
+ (petal_path % light) +
"</g>"
"</svg>"
)
# ---- 一套素材(浅色 / 深色各一组色) ---------------------------------------
LIGHT = dict(
light="#f7bcd0", deep="#e989aa", heart="#fdf6f8",
wood="#9a7a70", pattern="#f2bfd0",
)
DARK = dict(
light="#f0a0b8", deep="#c96a8b", heart="#2c2227",
wood="#a8837a", pattern="#7a4e5f",
)
def uris(p, asset_base=""):
"""asset_base 非空 -> 引用对象存储上的位图(键名与 export_assets.py 的产物一致)。
内联 SVG 是默认:gzip 后约 3.9KB、零额外请求。位图版 CSS 只有 10.6KB,但要多个
5 个请求共 ~49KB,总传输反而更大 —— 它存在的理由是素材能被非 CSS 场景复用。
换之前先确认桶已对公网可读(自定义域或 r2.dev),否则主题会整片 403。
"""
if asset_base:
b = asset_base.rstrip("/")
return {
"blossom_l": 'url("%s/blossom.webp")' % b,
"petal_l": 'url("%s/petal.webp")' % b,
"branch_l": 'url("%s/branch-left.webp")' % b,
"branch_r": 'url("%s/branch-right.webp")' % b,
"pattern": 'url("%s/pattern.webp")' % b,
}
return {
"blossom_l": uri(blossom(p["light"], p["deep"], p["heart"])),
"petal_l": uri(petal(p["light"])),
"branch_l": uri(branch(p["wood"], p["light"], p["deep"], p["heart"], flip=True)),
"branch_r": uri(branch(p["wood"], p["light"], p["deep"], p["heart"], flip=False)),
"pattern": uri(pattern(p["pattern"])),
}
NONE10 = "none, none, none, none, none, none, none, none, none, none"
AUTO10 = "auto, auto, auto, auto, auto, auto, auto, auto, auto, auto"
TOPLEFT10 = ("left top, left top, left top, left top, left top, left top, left top, "
"left top, left top, left top")
def deco(layers):
"""layers: [(var, size, position)],不足 10 槽补 none。"""
items, sizes, poss = [], [], []
for var, size, pos in layers:
items.append(var)
sizes.append(size)
poss.append(pos)
pad = 10 - len(layers)
items += ["none"] * pad
sizes += ["auto"] * pad
poss += ["left top"] * pad
return (", ".join(items), ", ".join(sizes), ", ".join(poss))
# ---- 每个页面一套位置 ------------------------------------------------------
# 位置一律两值(`关键字 ± 偏移`)。百分比相对「容器 − 图片」,写 calc 更可控;
# 这里用 left/right + % 与 px 偏移,保证换视口时花还贴在角落而不是飘到内容上。
SCOPES = {
"global": [
("var(--sk-branch-r)", "auto 280px", "right -16px bottom -22px"),
("var(--sk-blossom)", "auto 88px", "left 6% top 12%"),
("var(--sk-petal)", "32px", "left 14% top 24%"),
("var(--sk-petal)", "26px", "right 16% top 8%"),
],
"box": [
# 枝的细端在右上、粗端朝左下:放在右上角时"从角上垂下来",
# 尺寸收到 300px 才会有枝形 —— 390px 时可见部分只剩一团花(第一次就是这个问题)
("var(--sk-branch-r)", "auto 300px", "right -24px top -16px"),
("var(--sk-blossom)", "auto 80px", "left 5% bottom 16%"),
("var(--sk-petal)", "34px", "left 14% bottom 32%"),
("var(--sk-petal)", "24px", "right 22% bottom 8%"),
("var(--sk-blossom)", "auto 52px", "right 6% bottom 6%"),
],
"question": [
("var(--sk-branch-l)", "auto 300px", "left -34px bottom -24px"),
("var(--sk-blossom)", "auto 86px", "right 7% top 11%"),
("var(--sk-petal)", "30px", "right 17% top 24%"),
("var(--sk-petal)", "24px", "left 11% top 7%"),
],
"admin": [
# 列表页卡片几乎占满宽度,右侧的花会整个被卡片盖住 —— 只往左侧留白放
("var(--sk-branch-l)", "auto 260px", "left -28px top -24px"),
("var(--sk-blossom)", "auto 70px", "left 3% bottom 7%"),
("var(--sk-petal)", "26px", "left 11% bottom 15%"),
],
"profile": [
("var(--sk-branch-r)", "auto 280px", "right -28px bottom -24px"),
("var(--sk-blossom)", "auto 66px", "left 6% top 9%"),
("var(--sk-petal)", "28px", "left 15% top 19%"),
("var(--sk-petal)", "22px", "left 5% top 28%"),
],
"vote": [
("var(--sk-branch-l)", "auto 280px", "left -30px top -20px"),
("var(--sk-blossom)", "auto 78px", "right 8% top 16%"),
("var(--sk-petal)", "28px", "right 18% top 27%"),
("var(--sk-blossom)", "auto 52px", "right 6% bottom 8%"),
],
# 窄屏:卡片占满宽度,留白只剩顶栏上下那两条 —— 装饰层放什么都没有立足之地,
# 樱花改由底纹承担(@media 里把 pattern 缩小到 200px,密度上来才看得出来)
"narrow": [],
}
def scope_block(prefix, layers, indent=" "):
img, size, pos = deco(layers)
return (
f"{prefix} {{\n"
f"{indent}--tb-deco: {img};\n"
f"{indent}--tb-deco-size: {size};\n"
f"{indent}--tb-deco-position: {pos};\n"
"}"
)
def build(asset_base=""):
u = uris(LIGHT, asset_base)
d = uris(DARK, asset_base)
vars_light = "\n".join([
f" --sk-blossom: {u['blossom_l']};",
f" --sk-petal: {u['petal_l']};",
f" --sk-branch-l: {u['branch_l']};",
f" --sk-branch-r: {u['branch_r']};",
])
vars_dark = "\n".join([
f" --sk-blossom: {d['blossom_l']};",
f" --sk-petal: {d['petal_l']};",
f" --sk-branch-l: {d['branch_l']};",
f" --sk-branch-r: {d['branch_r']};",
])
light_block = f"""html[data-theme-style="new-sakura"] {{
--tb-bg: #fdf6f8;
--tb-surface: #ffffff;
--tb-surface-2: #fdf0f4;
--tb-surface-hover: #fff7fa;
--tb-navbar: #fdf6f8;
--tb-border: #f3dde5;
--tb-text: #3a2c31;
--tb-text-secondary: #6d555d;
/* 5.27:1(底色)/ 5.62:1(卡片),踩住 AA 下限 */
--tb-text-muted: #77626a;
/* #c14a72 在粉底上只有 4.39:1,同色相压深到 #b8436a = 4.87:1 */
--tb-primary: #b8436a;
--tb-primary-weak: rgba(184, 67, 106, 0.12);
--tb-on-primary: #ffffff;
--tb-shadow: 0 1px 2px rgba(150, 80, 105, 0.05), 0 12px 28px -18px rgba(150, 80, 105, 0.22);
--tb-shadow-hover: 0 1px 2px rgba(150, 80, 105, 0.07), 0 18px 40px -20px rgba(150, 80, 105, 0.30);
/* 花与瓣都是内联 SVG(data URI),没有外部素材 —— 所以这个主题不需要对象存储。
--sk-* 是花材的别名:同一个 URI 只在这里出现一次,下面各页面作用域引用变量,
否则 6 套位置会把 4 个 data URI 重复 20 多次,文件直接翻倍。 */
{vars_light}
/* 整页底纹:极淡的花与落瓣,260px 平铺。压在白底上只是一层纸纹,
不参与构图(真正的樱花由下面的装饰层负责)。 */
--tb-page-image: none;
--tb-page-pattern: {u['pattern']};
--tb-page-pattern-size: 300px 300px;
"""
# 全局默认位置:写在同一个块里(未单独配置的页面沿用这套)
body = light_block + deco_block_inline(SCOPES["global"]) + "}\n"
for key in ("box", "question", "admin", "profile", "vote"):
body += "\n" + scope_block(
f'html[data-theme-style="new-sakura"][data-page-scope="{key}"]', SCOPES[key]) + "\n"
dark_block = f"""
html[data-theme="dark"][data-theme-style="new-sakura"] {{
--tb-bg: #221a1e;
--tb-surface: #2c2227;
--tb-surface-2: #372a30;
--tb-surface-hover: rgba(255, 147, 180, 0.10);
--tb-navbar: #221a1e;
--tb-border: #47333b;
--tb-text: #f3e6ea;
--tb-text-secondary: #c9aeb8;
/* 5.82:1(底色)/ 5.26:1(卡片) */
--tb-text-muted: #ab9099;
--tb-primary: #ff93b4;
--tb-primary-weak: rgba(255, 147, 180, 0.16);
--tb-on-primary: #1b1014;
--tb-shadow: 0 1px 2px rgba(0, 0, 0, 0.34), 0 12px 28px -18px rgba(0, 0, 0, 0.58);
--tb-shadow-hover: 0 1px 2px rgba(0, 0, 0, 0.40), 0 18px 40px -20px rgba(0, 0, 0, 0.68);
{vars_dark}
/* 深色不铺底纹:浅粉花瓣压在深底上会变成一层噪点 */
--tb-page-image: linear-gradient(180deg, #251c21 0%, #221a1e 62%, #1e171b 100%);
--tb-page-pattern: none;
""" + deco_block_inline(SCOPES["global"], indent=" ") + "}\n"
for key in ("box", "question", "admin", "profile", "vote"):
dark_block += "\n" + scope_block(
f'html[data-theme="dark"][data-theme-style="new-sakura"][data-page-scope="{key}"]',
SCOPES[key]) + "\n"
narrow = (
"\n@media (max-width: 640px) {\n"
' html[data-theme-style="new-sakura"] {\n'
+ deco_block_inline(SCOPES["narrow"], indent=" ")
+ " /* 手机上唯一的留白是顶栏上下两条,装饰层没有立足之地 —— 缩小底纹让樱花密起来 */\n"
" --tb-page-pattern-size: 200px 200px;\n"
" }\n}\n"
)
return HEADER + body + dark_block + narrow + FOOTER
def deco_block_inline(layers, indent=" "):
img, size, pos = deco(layers)
return (
f"{indent}--tb-deco: {img};\n"
f"{indent}--tb-deco-size: {size};\n"
f"{indent}--tb-deco-position: {pos};\n"
)
HEADER = """/* 新·樱花(new-sakura):纯 CSS 樱花,不依赖任何图片素材。
和已有的 sakura 主题的区别:sakura 是"粉底 + 网点"的静态配色;这里把樱花本身
做成了可摆放的装饰层 —— 整朵花、带缺口的花瓣、带枝的花朵树,全部是内联 SVG
的 data URI,所以:换颜色只改一个色值、任意视口不糊、没有额外请求、不需要对象存储。
每个页面一套位置(--tb-deco / -size / -position 三组各 10 槽,必须等长):
global 未单独配置的页面 右下花枝 + 左上两瓣
box 箱主页 右上花枝垂下 + 左下花与落瓣
question 问题页 左下花枝 + 右上花与落瓣
admin 题目列表 左上花枝(只压顶部边距,不抢列表)
profile 设置页 右下花枝 + 左侧竖排小瓣
vote 投票页 左上花枝 + 右侧花与落瓣
≤640px 一律只留角落两朵小的 —— 卡片占满宽度后枝条会糊在字上
位置一律「两值 + 关键字」,不用三值语法(三值里混用 left/bottom 关键字是非法组合,
整条声明会作废,花瓣会静默回到左上角)。
生成:python dev/theme-tools/gen_new_sakura.py(改花形/位置请改那个脚本,别手改这里)
*/
"""
FOOTER = """
/* 组件微调(仅外观):顶栏与粉底融在一起后,UIkit 默认的 #999 链接色偏淡 */
html[data-theme-style="new-sakura"] .uk-navbar-nav > li > a {
color: var(--tb-text-secondary);
}
html[data-theme-style="new-sakura"] .uk-navbar-nav > li > a:hover {
color: var(--tb-primary);
}
/* 信息提示条:UIkit 的浅蓝在樱粉底上是唯一的冷色块,换成主色的弱化底 */
html[data-theme-style="new-sakura"] .uk-alert-primary {
background-color: var(--tb-primary-weak);
color: #9c3356;
}
html[data-theme="dark"][data-theme-style="new-sakura"] .uk-alert-primary {
color: #ffb3c8;
}
"""
if __name__ == "__main__":
import argparse
ap = argparse.ArgumentParser(description="生成 new-sakura 主题 CSS")
ap.add_argument("--asset-base", default="",
help="引用对象存储位图的前缀,如 https://r2.example.com/themes/new-sakura;"
"留空 = 内联 SVG(默认)")
ap.add_argument("--out", default=OUT, help="输出路径")
args = ap.parse_args()
css = build(args.asset_base)
with open(args.out, "w", encoding="utf-8", newline="\n") as f:
f.write(css)
print("已生成 %s(%.1f KB)%s" % (args.out, len(css.encode()) / 1024,
" [位图版]" if args.asset_base else " [内联 SVG]"))