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