#!/usr/bin/env python3
"""
Generate the SVG assets used by README.md / README/README.zh-CN.md.
The README borrows the look of https://pixshaft.com (dark "ink" background,
brand purple → cyan aurora, glass pills) and its real-device screenshots.
Everything is rendered as self-contained SVG so GitHub can show it as a plain
— no CSS, no fonts, no external requests (screenshots are inlined as
data URIs).
Inputs : snap/pixshaft/screens/*.webp (512×1138 real-device screenshots)
snap/pixshaft/shaft-logo.png (the app mark, from pixshaft.com)
snap/pixshaft/device/pixel_8/ (Google's Pixel 8 device-art frame, copied
from Android Studio's device-art-resources;
back.webp = bezel, mask.webp = screen corners
+ punch-hole, layout = display offset)
Outputs: snap/pixshaft/frames/.webp (each screenshot composited into the Pixel 8)
snap/pixshaft/hero-{en,zh}.svg
snap/pixshaft/capabilities-{en,zh}.svg
snap/pixshaft/tech-{en,zh}.svg
snap/pixshaft/cta-{en,zh}.svg
Usage: python3 scripts/build_readme_assets.py
"""
from __future__ import annotations
import base64
import html
import io
import re
from pathlib import Path
from PIL import Image
ROOT = Path(__file__).resolve().parent.parent
ASSETS = ROOT / "snap" / "pixshaft"
SCREENS = ASSETS / "screens"
FRAMES = ASSETS / "frames"
DEVICE = ASSETS / "device" / "pixel_8"
# ---- theme (tokens lifted from pixshaft.com's stylesheet) -------------------
INK = "#07060f"
INK2 = "#0c0a18"
GLASS = "#14122a"
BRAND = "#7c6cff"
BRAND2 = "#5b6ee1"
GLOW = "#a78bfa"
ACCENT = "#22d3ee"
PINK = "#f6339a"
FONT = ("Inter, Sora, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, "
"'PingFang SC', 'Hiragino Sans GB', 'Noto Sans CJK SC', 'Microsoft YaHei', "
"system-ui, sans-serif")
def esc(s: str) -> str:
return html.escape(s, quote=True)
def data_uri(path: Path) -> str:
return "data:image/webp;base64," + base64.b64encode(path.read_bytes()).decode()
def _device_layout() -> tuple[int, int, int, int]:
"""(display_w, display_h, offset_x, offset_y) from the device-art `layout` file."""
txt = (DEVICE / "layout").read_text()
dw, dh = (int(v) for v in re.search(r"display \{\s*width (\d+)\s*height (\d+)", txt).groups())
ox, oy = (int(v) for v in re.search(r"name device\s*x (\d+)\s*y (\d+)", txt).groups())
return dw, dh, ox, oy
def frame_in_device(shot: Path) -> Image.Image:
"""Composite a screenshot into the Pixel 8 device art, at screenshot resolution
(the frame is scaled down to the shot, never the other way round)."""
dw, dh, ox, oy = _device_layout()
back = Image.open(DEVICE / "back.webp").convert("RGBA")
mask = Image.open(DEVICE / "mask.webp").convert("RGBA")
img = Image.open(shot).convert("RGBA")
k = img.width / dw # frame → shot scale
fw, fh = round(back.width * k), round(back.height * k)
sx, sy = round(ox * k), round(oy * k)
sh = round(dh * k)
screen = img.resize((img.width, sh), Image.LANCZOS) if img.height != sh else img
canvas = Image.new("RGBA", (fw, fh), (0, 0, 0, 0))
canvas.alpha_composite(screen, (sx, sy))
canvas.alpha_composite(mask.resize((screen.width, sh), Image.LANCZOS), (sx, sy))
canvas.alpha_composite(back.resize((fw, fh), Image.LANCZOS))
return canvas
def image_data_uri(im: Image.Image) -> str:
buf = io.BytesIO()
im.save(buf, "WEBP", quality=88, method=6)
return "data:image/webp;base64," + base64.b64encode(buf.getvalue()).decode()
def text_width(s: str, size: float, weight: str = "normal") -> float:
"""Rough text measurement good enough for pill layout (CJK ≈ 1em, latin ≈ .56em)."""
w = 0.0
for ch in s:
if ord(ch) > 0x2E7F:
w += 0.98
elif ch in " ·":
w += 0.28
elif ch.isupper() or ch.isdigit():
w += 0.62
else:
w += 0.53
if weight in ("600", "700", "800", "bold"):
w *= 1.04
return w * size
# ---- shared defs -------------------------------------------------------------
def defs(uid: str) -> str:
return f"""
"""
def ink_background(uid: str, w: int, h: int, rx: int = 28, aurora=True) -> str:
out = [f'',
f'']
if aurora:
out.append(f''
f''
f''
f''
f'')
out.append(f'')
out.append(f'')
return "\n".join(out)
def logo(x: float, y: float, size: float) -> str:
"""The real Shaft mark (snap/pixshaft/shaft-logo.png, from pixshaft.com), inlined and rounded."""
png = base64.b64encode((ASSETS / "shaft-logo.png").read_bytes()).decode()
r = size * 0.22
return (f''
f'')
def pill(x: float, y: float, label: str, *, size=14, h=36, fill="#ffffff", fill_op=".06",
stroke_op=".14", color="#ffffff", color_op=".88", weight="600", dot: str | None = None,
gradient: str | None = None, pad=18) -> tuple[str, float]:
tw = text_width(label, size, weight)
dot_w = 16 if dot else 0
w = pad * 2 + tw + dot_w
r = h / 2
if gradient:
rect = f''
else:
rect = (f'')
parts = [rect]
tx = x + pad
if dot:
parts.append(f'')
tx += dot_w
parts.append(f'{esc(label)}')
return "\n".join(parts), w
def svg(w: int, h: int, body: str, title: str) -> str:
return (f'\n')
def write(path: Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
print(f" wrote {path.relative_to(ROOT)} ({len(content.encode())//1024} KB)")
# ---- 1. framed screenshots ---------------------------------------------------
def build_frames() -> None:
FRAMES.mkdir(parents=True, exist_ok=True)
for src in sorted(SCREENS.glob("*.webp")):
out = FRAMES / f"{src.stem}.webp"
frame_in_device(src).save(out, "WEBP", quality=88, method=6)
print(f" wrote {out.relative_to(ROOT)} ({out.stat().st_size//1024} KB)")
# ---- 2. hero ------------------------------------------------------------------
HERO = {
"en": dict(
eyebrow="Open source · Material You · No ads",
h1=("The whole of Pixiv,", "in your pocket."),
sub=("An open-source third-party Pixiv client for Android —",
"illustrations · manga · novels · rankings · FANBOX · pixiv COMIC.",
"Fluid, Material You, and it doesn't feel third-party at all."),
btn1="Get it on Google Play", btn2="View on GitHub",
foot="Android 7.0+ · direct connection in mainland China · free & open source",
),
"zh": dict(
eyebrow="开源 · Material You · 无广告",
h1=("把整个 Pixiv", "装进口袋"),
sub=("PixShaft 是一个开源的 Pixiv 第三方安卓客户端:",
"插画 · 漫画 · 小说 · 排行榜 · FANBOX · pixiv COMIC,",
"流畅、Material You —— 能想到的体验都在这里。"),
btn1="Google Play 下载", btn2="在 GitHub 查看",
foot="支持 Android 7.0+ · 中国大陆可直连 · 开源无广告",
),
}
def build_hero(lang: str) -> None:
t = HERO[lang]
uid = "h"
W, H = 1200, 600
b = [defs(uid), ink_background(uid, W, H)]
# left column
b.append(logo(64, 64, 56))
b.append(f'Shaft')
p, _ = pill(64, 150, t["eyebrow"], size=13, h=32, dot=ACCENT, pad=16)
b.append(p)
h1_size = 58 if lang == "en" else 62
b.append(f''
f'{esc(t["h1"][0])}{esc(t["h1"][1])}')
sub_lines = "".join(f'{esc(line)}' for i, line in enumerate(t["sub"]))
b.append(f'{sub_lines}')
p1, w1 = pill(64, 478, t["btn1"], size=15, h=46, gradient=f"{uid}-brand", color="#ffffff", color_op="1", pad=24)
p2, _ = pill(64 + w1 + 14, 478, t["btn2"], size=15, h=46, fill_op=".08", stroke_op=".22", pad=24)
b.append(p1); b.append(p2)
b.append(f'{esc(t["foot"])}')
# right: three Pixel 8 frames, clipped to the card; glow behind the middle one
b.append(f'')
shots = [("gallery", 716, 128, -7), ("home", 964, 118, 7), ("detail", 840, 58, 0)]
for name, x, y, rot in shots:
framed = frame_in_device(SCREENS / f"{name}.webp")
w = 224
h = w * framed.height / framed.width # aspect comes from the device art, not a constant
cx, cy = x + w / 2, y + h / 2
if rot == 0:
b.append(f'')
b.append(f'')
b.append('')
write(ASSETS / f"hero-{lang}.svg", svg(W, H, "\n".join(b), "Shaft — " + " ".join(t["h1"])))
# ---- 3. capabilities chips ----------------------------------------------------
CAPS = {
"zh": ["插画", "漫画", "小说", "排行榜", "PixiVision", "FANBOX", "pixiv COMIC", "热门标签", "关注动态",
"动图补帧", "GIF / MP4", "批量下载", "断点续传", "以图搜图", "稍后再看", "本地书库", "网络自检",
"图片加速", "平板双栏", "多账号", "自定义主题色", "深色模式", "Material You"],
"en": ["Illustrations", "Manga", "Novels", "Rankings", "PixiVision", "FANBOX", "pixiv COMIC", "Trending tags",
"Following feed", "Ugoira interpolation", "GIF / MP4", "Batch download", "Resumable downloads",
"Reverse image search", "Watch later", "Local library", "Network self-check", "Image mirrors",
"Tablet two-pane", "Multi-account", "Custom accent color", "Dark mode", "Material You"],
}
CAP_TITLE = {"zh": "为「逛 Pixiv」而生", "en": "Built for browsing Pixiv"}
DOTS = [BRAND, ACCENT, PINK, GLOW, "#00bb7f", "#fcbb00"]
def build_caps(lang: str) -> None:
uid = "c"
W = 1200
pad = 40
x, y = pad, 92
rows = []
row_h, gap = 40, 12
for i, label in enumerate(CAPS[lang]):
_, w = pill(0, 0, label, size=15, h=row_h, dot=DOTS[i % len(DOTS)])
if x + w > W - pad:
x = pad
y += row_h + gap
p, w = pill(x, y, label, size=15, h=row_h, dot=DOTS[i % len(DOTS)])
rows.append(p)
x += w + gap
H = y + row_h + pad
b = [defs(uid), ink_background(uid, W, H),
f'{esc(CAP_TITLE[lang])}']
b += rows
write(ASSETS / f"capabilities-{lang}.svg", svg(W, H, "\n".join(b), CAP_TITLE[lang]))
# ---- 4. tech stack -------------------------------------------------------------
TECH = {
"zh": [("Kotlin", "Coroutines · Flow"), ("Material Design 3", "Material You · MD3-E"),
("MVVM", "Repository Pattern"), ("Retrofit 2", "OkHttp · SSE 流式"), ("Room", "MMKV"),
("Glide", "Lottie · ZoomImage"), ("自研 Feeds 框架", "本地优先 · 统一分页"),
("自研 witstudio", "自有 UI 基建 · 零 QMUI"), ("actionqueue", "持久化限流队列"),
("Cronet", "自定义 DNS · IPv4-only 兜底"), ("Activity Embedding", "平板横屏双栏"),
("Target SDK 36", "Android 16 · Min 24")],
"en": [("Kotlin", "Coroutines · Flow"), ("Material Design 3", "Material You · MD3-E"),
("MVVM", "Repository pattern"), ("Retrofit 2", "OkHttp · SSE streaming"), ("Room", "MMKV"),
("Glide", "Lottie · ZoomImage"), ("In-house Feeds", "local-first · unified paging"),
("In-house witstudio", "own UI kit · zero QMUI"), ("actionqueue", "persistent rate-limited queue"),
("Cronet", "custom DNS · IPv4-only fallback"), ("Activity Embedding", "tablet two-pane"),
("Target SDK 36", "Android 16 · Min 24")],
}
TECH_TITLE = {"zh": "工程上同样讲究", "en": "Under the hood"}
def build_tech(lang: str) -> None:
uid = "t"
W = 1200
pad, gap = 40, 14
cols = 4
cw = (W - pad * 2 - gap * (cols - 1)) / cols
ch = 84
items = TECH[lang]
rows_n = (len(items) + cols - 1) // cols
top = 96
H = int(top + rows_n * ch + (rows_n - 1) * gap + pad)
b = [defs(uid), ink_background(uid, W, H),
f'{esc(TECH_TITLE[lang])}']
for i, (title, sub) in enumerate(items):
r, c = divmod(i, cols)
x = pad + c * (cw + gap)
y = top + r * (ch + gap)
b.append(f'')
b.append(f'')
b.append(f'{esc(title)}')
b.append(f'{esc(sub)}')
write(ASSETS / f"tech-{lang}.svg", svg(W, H, "\n".join(b), TECH_TITLE[lang]))
# ---- 5. CTA banner -------------------------------------------------------------
CTA = {
"zh": dict(eyebrow="免费 · 开源 · 无广告", h="现在就开始逛 Pixiv",
sub="Google Play 一键安装,或在 GitHub 下载最新 APK。完全开源,欢迎 Star 与贡献。",
b1="Google Play 下载", b2="GitHub Releases"),
"en": dict(eyebrow="Free · Open source · No ads", h="Start browsing Pixiv now",
sub="Install from Google Play, or grab the latest APK from GitHub Releases. Fully open source — stars and PRs welcome.",
b1="Get it on Google Play", b2="GitHub Releases"),
}
def build_cta(lang: str) -> None:
t = CTA[lang]
uid = "k"
W, H = 1200, 250
b = [defs(uid), ink_background(uid, W, H)]
p, w = pill(0, 0, t["eyebrow"], size=13, h=30, dot=ACCENT, pad=16)
p, _ = pill((W - w) / 2, 36, t["eyebrow"], size=13, h=30, dot=ACCENT, pad=16)
b.append(p)
b.append(f'{esc(t["h"])}')
b.append(f'{esc(t["sub"])}')
_, w1 = pill(0, 0, t["b1"], size=15, h=44, pad=24)
_, w2 = pill(0, 0, t["b2"], size=15, h=44, pad=24)
x0 = (W - (w1 + 14 + w2)) / 2
p1, _ = pill(x0, 180, t["b1"], size=15, h=44, gradient=f"{uid}-brand", color_op="1", pad=24)
p2, _ = pill(x0 + w1 + 14, 180, t["b2"], size=15, h=44, fill_op=".08", stroke_op=".22", pad=24)
b.append(p1); b.append(p2)
write(ASSETS / f"cta-{lang}.svg", svg(W, H, "\n".join(b), t["h"]))
if __name__ == "__main__":
print("building README assets…")
build_frames()
for lang in ("en", "zh"):
build_hero(lang)
build_caps(lang)
build_tech(lang)
build_cta(lang)
print("done.")