619d5f921d
- Caption-localized the 5 iPhone 6.7" + 5 iPad 12.9" screenshots into the 5 non-English locales, reusing the en-US framed captures (in-frame UI stays EN). Tooling in fastlane/screenshot_localize/ (Pillow: repaints the pastel caption band, re-renders headline/subtitle in SF Pro; auto-fits + wraps per language). - Rewrote release_notes 1.2.0 in all 6 languages: since 1.1.5 never shipped publicly (last public = 1.1.4), the notes consolidate 1.1.5 + 1.2.0 features. - Fastfile: new `publish` lane — uploads metadata + screenshots for an existing TestFlight build (skip_binary_upload, no submit) so offer codes can be set up before submission. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BkanrydYtrme8wipTzWssG
143 lines
4.2 KiB
Python
143 lines
4.2 KiB
Python
#!/usr/bin/env python3
|
|
"""Localize App Store caption bands. Repaints top band + redraws localized text."""
|
|
import json, os
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
SRC = os.path.join(HERE, "..", "en-US")
|
|
FONT = "/System/Library/Fonts/SFNS.ttf"
|
|
LANGS = ["es-ES", "de-DE", "fr-FR", "it", "pt-BR"]
|
|
FILES = [ # (index, filename, device)
|
|
(i, dev) for i in range(5)
|
|
for dev in ("IPHONE_67", "IPAD_PRO_3GEN_129")
|
|
]
|
|
|
|
with open(os.path.join(HERE, "translations.json")) as f:
|
|
T = json.load(f)
|
|
|
|
|
|
def font(size, weight):
|
|
ft = ImageFont.truetype(FONT, size)
|
|
try:
|
|
ft.set_variation_by_name(weight)
|
|
except Exception:
|
|
pass
|
|
return ft
|
|
|
|
|
|
def detect_phone_top(im):
|
|
w, h = im.size
|
|
px = im.load()
|
|
def dark(c): return c[0] < 70 and c[1] < 70 and c[2] < 70
|
|
for y in range(int(h * 0.10), int(h * 0.6), 2):
|
|
run = mx = 0
|
|
for x in range(0, w, 4):
|
|
run = run + 4 if dark(px[x, y]) else 0
|
|
if run > mx:
|
|
mx = run
|
|
if mx > w * 0.35:
|
|
return y
|
|
return int(h * 0.20)
|
|
|
|
|
|
def wrap(draw, text, ft, max_w, max_lines):
|
|
words = text.split()
|
|
lines, cur = [], ""
|
|
for wd in words:
|
|
trial = (cur + " " + wd).strip()
|
|
if draw.textlength(trial, font=ft) <= max_w or not cur:
|
|
cur = trial
|
|
else:
|
|
lines.append(cur)
|
|
cur = wd
|
|
if cur:
|
|
lines.append(cur)
|
|
return lines[:max_lines] if len(lines) <= max_lines else None
|
|
|
|
|
|
def fit_headline(draw, text, max_w, start, floor, weight, max_lines=3):
|
|
size = start
|
|
while size >= floor:
|
|
ft = font(size, weight)
|
|
lines = wrap(draw, text, ft, max_w, max_lines)
|
|
if lines and all(draw.textlength(l, font=ft) <= max_w for l in lines):
|
|
return ft, lines, size
|
|
size -= 2
|
|
ft = font(floor, weight)
|
|
lines = wrap(draw, text, ft, max_w, max_lines) or [text]
|
|
return ft, lines, floor
|
|
|
|
|
|
def block_height(draw, lines, ft, lh):
|
|
asc, desc = ft.getmetrics()
|
|
return int((asc + desc) * lh * len(lines))
|
|
|
|
|
|
def render(base_path, out_path, headline, subtitle, is_ipad):
|
|
im = Image.open(base_path).convert("RGB")
|
|
w, h = im.size
|
|
bg = im.getpixel((10, 10))
|
|
phone_top = detect_phone_top(im)
|
|
d = ImageDraw.Draw(im)
|
|
# erase band
|
|
d.rectangle([0, 0, w, phone_top], fill=bg)
|
|
|
|
max_w = int(w * 0.88)
|
|
if is_ipad:
|
|
h_start, h_floor, s_size = 96, 60, 66
|
|
else:
|
|
h_start, h_floor, s_size = 72, 44, 50
|
|
lh = 1.12
|
|
|
|
hf, hlines, hpx = fit_headline(d, headline, max_w, h_start, h_floor, "Bold")
|
|
sf = font(s_size, "Regular")
|
|
slines = wrap(d, subtitle, sf, max_w, 3) or [subtitle]
|
|
|
|
hb = block_height(d, hlines, hf, lh)
|
|
sb = block_height(d, slines, sf, lh)
|
|
gap = int(s_size * 0.6)
|
|
total = hb + gap + sb
|
|
|
|
# vertically center within [0, phone_top) with slight upward bias
|
|
bottom_gap = int(phone_top * 0.12)
|
|
avail = phone_top - bottom_gap
|
|
y = max(int(phone_top * 0.06), (avail - total) // 2)
|
|
|
|
def draw_lines(lines, ft):
|
|
nonlocal y
|
|
asc, desc = ft.getmetrics()
|
|
step = int((asc + desc) * lh)
|
|
for ln in lines:
|
|
tw = d.textlength(ln, font=ft)
|
|
d.text(((w - tw) / 2, y), ln, font=ft, fill=(0, 0, 0))
|
|
y += step
|
|
|
|
draw_lines(hlines, hf)
|
|
y += gap
|
|
draw_lines(slines, sf)
|
|
|
|
im.save(out_path)
|
|
ok = y <= phone_top
|
|
return hpx, len(hlines), len(slines), ok, y, phone_top
|
|
|
|
|
|
rows = []
|
|
for lang in LANGS:
|
|
outdir = os.path.join(HERE, "..", lang)
|
|
os.makedirs(outdir, exist_ok=True)
|
|
for i, dev in FILES:
|
|
fn = f"{i}_APP_{dev}_{i}.png"
|
|
base = os.path.join(SRC, fn)
|
|
out = os.path.join(outdir, fn)
|
|
tr = T[lang][i]
|
|
hpx, hl, sl, ok, ybot, pt = render(base, out, tr["h"], tr["s"], "IPAD" in dev)
|
|
rows.append((lang, i, "ipad" if "IPAD" in dev else "iphone", hpx, hl, sl, ok, ybot, pt))
|
|
|
|
print(f"{'lang':7} {'i':2} {'dev':6} {'hpx':4} {'hL':3} {'sL':3} {'ok':3} {'ybot':5} {'ptop':5}")
|
|
bad = 0
|
|
for r in rows:
|
|
print(f"{r[0]:7} {r[1]:<2} {r[2]:6} {r[3]:<4} {r[4]:<3} {r[5]:<3} {str(r[6]):5} {r[7]:<5} {r[8]:<5}")
|
|
if not r[6]:
|
|
bad += 1
|
|
print(f"\nTotal images: {len(rows)} | text-overlaps-phone failures: {bad}")
|