"""把 new-sakura 的内联 SVG 导出成位图,并上传到对象存储。
为什么要这一步:主题本身是纯 CSS(SVG 内联在 CSS 里),线上不依赖任何外部素材。
但对象存储上留一份位图有它的用处:
· 桶开了公开域名后,可以把 CSS 里的 url() 换成绝对地址(注意:服务端有 gzip,
内联版总传输 3.9KB 比位图版的 51KB 更小,换位图不为省流量);
· 分享卡 / 其它非 CSS 场景只能用位图;
· 换人维护时不用去读那一长串 data URI。
用法(项目根):
python dev/theme-tools/export_assets.py # 只导出到 outputs/sakura-assets
python dev/theme-tools/export_assets.py --upload # 导出,并把 CSS + 位图 + INSTALL.txt 传到桶里
"""
import argparse
import io
import os
import re
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(os.path.dirname(HERE))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(ROOT, "dev", "theme-builder"))
import gen_new_sakura as G # noqa: E402
OUT_DIR = os.path.join(ROOT, "outputs", "sakura-assets")
# (文件名, svg 源, 导出像素宽, 高)
def assets():
p = G.LIGHT
return [
("branch-right", G.branch(p["wood"], p["light"], p["deep"], p["heart"], flip=False), 840, 640),
("branch-left", G.branch(p["wood"], p["light"], p["deep"], p["heart"], flip=True), 840, 640),
("blossom", G.blossom(p["light"], p["deep"], p["heart"]), 400, 400),
("petal", G.petal(p["light"]), 240, 336),
("pattern", G.pattern(p["pattern"]), 600, 600),
]
def render(svg, w, h, out_png):
"""用无头 Chrome 渲染成透明底 PNG。
走命令行而不是 Playwright:本机只有 node 版 playwright,python 侧没装;
而 `--default-background-color=00000000` 正好能给透明底。
⚠️ `--window-size` 不等于 CSS 视口(实测 1080 只有 985),所以窗口开大一圈
并让内容居中,再按目标尺寸从中心裁 —— 这样尺寸误差被吸收掉,不必去猜差多少。
"""
import subprocess
import tempfile
from PIL import Image
pad = 120
html = (
''
"" + svg
)
fd, html_path = tempfile.mkstemp(suffix=".html")
os.close(fd)
with open(html_path, "w", encoding="utf-8") as f:
f.write(html)
chrome = "C:/Program Files/Google/Chrome/Application/chrome.exe"
cmd = [
chrome, "--headless=old", "--disable-gpu", "--hide-scrollbars",
"--force-device-scale-factor=1", "--default-background-color=00000000",
"--window-size=%d,%d" % (w + pad, h + pad),
"--screenshot=" + out_png,
"file:///" + html_path.replace("\\", "/"),
]
subprocess.run(cmd, check=True, capture_output=True, timeout=60)
os.remove(html_path)
img = Image.open(out_png).convert("RGBA")
cw, ch = img.size
left, top = max(0, (cw - w) // 2), max(0, (ch - h) // 2)
img.crop((left, top, left + w, top + h)).save(out_png)
def to_webp(png_path, webp_path, quality=92):
from PIL import Image
img = Image.open(png_path).convert("RGBA")
img.save(webp_path, "WEBP", quality=quality, method=6)
def read_r2(path):
cfg = {}
for line in open(path, encoding="utf-8"):
m = re.match(r'^\s*([a-z_]+)\s*=\s*"?([^"\n]*)"?\s*$', line.strip())
if m:
cfg[m.group(1)] = m.group(2).strip()
return cfg
def pack_readme(prefix, object_base, sizes):
"""生成包内 INSTALL.txt。域名与体积都写实数,避免 README 与实际脱节。"""
base = (object_base or "").rstrip("/")
total_kb = sum(v for k, v in sizes.items() if k.endswith(".webp")) / 1024
css_kb = sizes.get("new-sakura.css", 0) / 1024
urls = ""
if base:
urls = "\n公开地址(已验证可匿名 GET)\n--------------------------\n" + "\n".join(
" %s/%s" % (base, n) for n in ["new-sakura.css"] + [
"%s.webp" % k for k in ["branch-right", "branch-left", "blossom", "petal", "pattern"]])
return """new-sakura(新·樱花)主题包
============================
来源:TamaBox 仓库 static/css/themes/new-sakura.css(生成产物,勿手改)
生成:dev/theme-tools/gen_new_sakura.py(花形/位置/配色都在里面)
位图:dev/theme-tools/export_assets.py(从同一份 SVG 导出,不是另画的)
说明:docs/theme-new-sakura.md
对象前缀:%s/
文件
----
new-sakura.css 主题本体 %5.1f KB(纯 CSS,樱花是内联 SVG,零外部请求)
branch-right.webp 花枝 840x640 透明底(细端在右上)
branch-left.webp 同上镜像
blossom.webp 单朵樱花 400x400 透明底
petal.webp 单瓣 240x336 透明底
pattern.webp 平铺底纹 600x600 透明底
位图合计 %5.1f KB
怎么用
------
线上只需要 new-sakura.css:放进仓库 static/css/themes/,在 conf/app.ini 的
[theme] available 里加上 new-sakura,再重新 go build(主题 CSS 是编译期 embed,
只重启不重编不会生效)。
WebP 不是必需品 —— CSS 里的樱花是矢量,比位图清晰且没有额外请求。留着的用处:
1) 素材要单独复用:python dev/theme-tools/gen_new_sakura.py --asset-base %s
(CSS 从 %.1f KB 降到 10.6 KB,但多 5 个请求共 48.9 KB,总传输更大)
2) 非 CSS 场景(分享卡、海报)只能用位图
%s
注意
----
直连桶的 S3 endpoint(*.r2.cloudflarestorage.com)会 403,那里需要签名。
浏览器直读只能用自定义域 / r2.dev 或 CDN。
缓存:位图 1 天、CSS 5 分钟、本文件 60 秒 —— 对象键不带版本,所以**不用** immutable。
要一年长缓存,得把内容哈希写进文件名(届时 CSS 里的地址也要跟着换)。
""" % (prefix, css_kb, total_kb,
base or "https://<公开域名>/" + prefix.strip("/"), css_kb, urls)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--upload", action="store_true", help="导出后上传到 data/r2.txt 里的桶")
ap.add_argument("--prefix", default="themes/new-sakura", help="对象键前缀,默认 themes/new-sakura")
ap.add_argument("--public-base", default="",
help="公开访问的主机(如 https://r2.example.com,不带路径);"
"默认读 data/r2.txt 的 image_public_base 或 image_bucket_cdn_host,"
"都没有则在 INSTALL.txt 里写占位")
ap.add_argument("--r2", default=os.path.join(ROOT, "data", "r2.txt"))
args = ap.parse_args()
os.makedirs(OUT_DIR, exist_ok=True)
files, sizes = [], {}
for name, svg, w, h in assets():
png = os.path.join(OUT_DIR, name + ".png")
webp = os.path.join(OUT_DIR, name + ".webp")
render(svg, w, h, png)
to_webp(png, webp)
os.remove(png)
size = os.path.getsize(webp)
sizes[name + ".webp"] = size
print("导出 %-13s %dx%d %.1f KB" % (name, w, h, size / 1024))
files.append((name + ".webp", webp))
if not args.upload:
print("\n只导出,未上传(加 --upload 才传)。目录:%s" % OUT_DIR)
return
import base64
import serve
raw = read_r2(args.r2)
# 与 conf/app.ini 同名:image_bucket_cdn_host 只是主机名(不带 scheme)
cdn_host = raw.get("image_bucket_cdn_host", "").strip().strip("/")
public_host = (args.public_base or raw.get("image_public_base")
or ("https://" + cdn_host if cdn_host else "")).rstrip("/")
object_base = ("%s/%s" % (public_host, args.prefix.strip("/"))) if public_host else ""
cfg = serve.S3Config(dict(
endpoint=raw["image_endpoint"], region="auto", bucket=raw["image_bucket"],
prefix=args.prefix, pathStyle=True,
accessKey=raw["image_access_id"], secretKey=raw["image_access_secret"],
))
# 键名不带版本,就不配 immutable —— 否则重新导出后一年内谁看到的都还是旧图。
payload = [{"key": name, "name": name, "contentType": "image/webp",
"cacheControl": "public, max-age=86400",
"data": base64.b64encode(open(path, "rb").read()).decode()}
for name, path in files]
css_path = os.path.join(ROOT, "static", "css", "themes", "new-sakura.css")
css = open(css_path, "rb").read()
sizes["new-sakura.css"] = len(css)
payload.append({"key": "new-sakura.css", "contentType": "text/css; charset=utf-8",
"cacheControl": "public, max-age=300",
"data": base64.b64encode(css).decode()})
readme = pack_readme(args.prefix, object_base, sizes).encode("utf-8")
payload.append({"key": "INSTALL.txt", "contentType": "text/plain; charset=utf-8",
"cacheControl": "public, max-age=60",
"data": base64.b64encode(readme).decode()})
res = serve.upload_files(cfg, payload)
for r in res["files"]:
print(("OK " if r["ok"] else "FAIL ") + r["key"] + ("" if r["ok"] else " " + str(r.get("error"))))
print("\n上传 %d/%d" % (res["total"] - res["failed"], res["total"]))
if object_base:
print("公开地址:%s/" % object_base)
if __name__ == "__main__":
main()