#!/usr/bin/env python3 """Render the approved Portfolio Journal panorama for 12.9-inch iPad. Each App Store panel is exactly 2732 x 2048 (APP_IPAD_PRO_3GEN_129). Run with Scripts/aso/.venv/bin/python. """ from __future__ import annotations import math from pathlib import Path from PIL import Image, ImageDraw, ImageFilter, ImageFont import frame_panorama_codex as iphone_panorama ROOT = Path(__file__).resolve().parents[2] RAW = ROOT / "build/screenshots/src_raw_ipad" PANEL_W = 2732 HEIGHT = 2048 PANELS = 5 WIDTH = PANEL_W * PANELS INK = iphone_panorama.INK MUTED = iphone_panorama.MUTED GREEN = iphone_panorama.GREEN BLUE = iphone_panorama.BLUE NAVY = iphone_panorama.NAVY DISPLAY_FONT = iphone_panorama.DISPLAY_FONT TEXT_FONT = iphone_panorama.TEXT_FONT CONDENSED_FONT = iphone_panorama.CONDENSED_FONT LOCALIZED_SPECS = iphone_panorama.LOCALIZED_SPECS # The final iPhone module currently exposes the localized specs but not its # English list as a named constant. These are the matching source strings from # which that localization set was written. If the shared renderer later exports # its English specs, this script automatically uses them instead. _ENGLISH_FALLBACK = [ { "eyebrow": "YOUR NET WORTH", "headline": ("KNOW YOUR", "NET WORTH."), "body": "Everything you own, at a glance.", }, { "eyebrow": "REAL WEALTH. REAL MOMENTUM.", "headline": ("WATCH IT", "GROW."), "stat": "+22.7%", "body": "Since day one — and still climbing.", }, { "eyebrow": "THE WHOLE PORTFOLIO", "headline": ("EVERYTHING", "YOU OWN."), "body": "Stocks · property · crypto · cash", }, { "eyebrow": "CLARITY BUILDS CONVICTION", "headline": ("FIND YOUR", "WINNERS."), "body": "Real CAGR. Zero guesswork.", }, { "eyebrow": "PRIVATE BY DESIGN", "headline": ("NO BANK", "LOGINS. EVER."), "body": "Your numbers never leave your iPhone.", }, ] DEFAULT_ENGLISH_SPECS = getattr( iphone_panorama, "DEFAULT_ENGLISH_SPECS", getattr(iphone_panorama, "ENGLISH_SPECS", _ENGLISH_FALLBACK), ) def font(path: str, size: int, index: int = 0) -> ImageFont.FreeTypeFont: return ImageFont.truetype(path, size=size, index=index) def contains_cjk(text: str) -> bool: return iphone_panorama.contains_cjk(text) def japanese_font_path() -> str: return iphone_panorama.japanese_font_path() def face_for( text: str, latin_path: str, size: int, index: int = 0 ) -> ImageFont.FreeTypeFont: if contains_cjk(text): return font(japanese_font_path(), size) return font(latin_path, size, index) def rounded_mask(size: tuple[int, int], radius: int) -> Image.Image: mask = Image.new("L", size, 0) ImageDraw.Draw(mask).rounded_rectangle( (0, 0, size[0] - 1, size[1] - 1), radius=radius, fill=255 ) return mask def gradient_background() -> Image.Image: """Near-black navy canvas with continuous atmospheric blue/green light.""" column = Image.new("RGB", (1, HEIGHT)) pixels = column.load() for y in range(HEIGHT): t = y / (HEIGHT - 1) pixels[0, y] = ( round(10 - 7 * t), round(21 - 12 * t), round(42 - 20 * t), ) background = column.resize((WIDTH, HEIGHT)).convert("RGBA") glow_layer = Image.new("RGBA", background.size, (0, 0, 0, 0)) glow_draw = ImageDraw.Draw(glow_layer) glows = [ (680, 570, 940, BLUE, 56), (2950, 1390, 930, GREEN, 38), (5350, 510, 880, BLUE, 42), (7770, 1350, 980, GREEN, 34), (10350, 500, 920, BLUE, 43), (12900, 1370, 980, GREEN, 32), ] for cx, cy, radius, color, alpha in glows: glow_draw.ellipse( (cx - radius, cy - radius, cx + radius, cy + radius), fill=color + (alpha,), ) glow_layer = glow_layer.filter(ImageFilter.GaussianBlur(330)) background.alpha_composite(glow_layer) grid = Image.new("RGBA", background.size, (0, 0, 0, 0)) grid_draw = ImageDraw.Draw(grid) for x in range(0, WIDTH, 160): grid_draw.line((x, 0, x, HEIGHT), fill=(93, 132, 181, 11), width=1) for y in range(0, HEIGHT, 160): grid_draw.line((0, y, WIDTH, y), fill=(93, 132, 181, 9), width=1) background.alpha_composite(grid) return background def draw_growth_thread(canvas: Image.Image) -> None: """Run one electric rising line continuously through all five panels.""" points: list[tuple[int, int]] = [] anchors = [ 1460, 1420, 1470, 1310, 1360, 1180, 1240, 1030, 1090, 880, 940, 750, 820, 650, 710, 560, ] for i, y in enumerate(anchors): points.append((round(i * WIDTH / (len(anchors) - 1)), y)) glow = Image.new("RGBA", canvas.size, (0, 0, 0, 0)) gd = ImageDraw.Draw(glow) gd.line(points, fill=BLUE + (78,), width=42, joint="curve") glow = glow.filter(ImageFilter.GaussianBlur(34)) canvas.alpha_composite(glow) line = Image.new("RGBA", canvas.size, (0, 0, 0, 0)) ld = ImageDraw.Draw(line) ld.line(points, fill=BLUE + (138,), width=6, joint="curve") for x, y in points[1:-1]: ld.ellipse((x - 9, y - 9, x + 9, y + 9), fill=GREEN + (188,)) canvas.alpha_composite(line) def text_width( draw: ImageDraw.ImageDraw, text: str, face: ImageFont.FreeTypeFont ) -> float: return draw.textlength(text, font=face) def draw_tracking_text( draw: ImageDraw.ImageDraw, xy: tuple[int, int], text: str, face: ImageFont.FreeTypeFont, fill: tuple[int, int, int], tracking: int, ) -> None: x, y = xy for char in text: draw.text((x, y), char, font=face, fill=fill) x += text_width(draw, char, face) + tracking def fit_display( draw: ImageDraw.ImageDraw, lines: tuple[str, ...], max_width: int ) -> ImageFont.FreeTypeFont: is_cjk = any(contains_cjk(line) for line in lines) display_path = japanese_font_path() if is_cjk else DISPLAY_FONT size = 166 if is_cjk else 184 while size > 126: face = font(display_path, size) if max(text_width(draw, line, face) for line in lines) <= max_width: return face size -= 4 return font(display_path, size) def fit_body( draw: ImageDraw.ImageDraw, text: str, max_width: int ) -> ImageFont.FreeTypeFont: size = 40 while size > 30: face = face_for(text, TEXT_FONT, size) if text_width(draw, text, face) <= max_width: return face size -= 1 return face_for(text, TEXT_FONT, size) def draw_campaign_copy(canvas: Image.Image, panel: int, spec: dict) -> None: draw = ImageDraw.Draw(canvas) left = panel * PANEL_W + 150 right = (panel + 1) * PANEL_W - 150 top = 76 eyebrow = face_for(spec["eyebrow"], CONDENSED_FONT, 34) draw_tracking_text(draw, (left, top), spec["eyebrow"], eyebrow, GREEN, 5) index = font(CONDENSED_FONT, 32) index_text = f"0{panel + 1} / 05" draw.text( (right - text_width(draw, index_text, index), top), index_text, font=index, fill=(100, 118, 145), ) draw.rounded_rectangle((left, 139, right, 147), radius=4, fill=(38, 57, 82)) draw.rounded_rectangle((left, 139, left + 165, 147), radius=4, fill=GREEN) lines = tuple(spec["headline"]) display = fit_display(draw, lines, 1640) line_h = round( display.size * (1.04 if any(contains_cjk(line) for line in lines) else 0.86) ) y = 171 headline_bottom = y for line in lines: draw.text((left + 6, y + 9), line, font=display, fill=(23, 68, 127)) draw.text((left, y), line, font=display, fill=INK) headline_bottom = max( headline_bottom, draw.textbbox((left, y), line, font=display)[3], ) y += line_h body = fit_body(draw, spec["body"], 1060) body_y = headline_bottom + 18 draw.text((left, body_y), spec["body"], font=body, fill=MUTED) if spec.get("stat"): stat = font(DISPLAY_FONT, 112) stat_x = right - text_width(draw, spec["stat"], stat) draw.text((stat_x, 457), spec["stat"], font=stat, fill=GREEN) def build_ipad(shot: Image.Image, target_w: int) -> Image.Image: """Build a slim, uniform titanium landscape iPad frame.""" outer = max(22, round(target_w * 0.016)) bezel = max(20, round(target_w * 0.014)) screen_w = target_w - 2 * (outer + bezel) screen_h = round(screen_w * shot.height / shot.width) total_h = screen_h + 2 * (outer + bezel) outer_radius = round(target_w * 0.036) ipad = Image.new("RGBA", (target_w, total_h), (0, 0, 0, 0)) draw = ImageDraw.Draw(ipad) draw.rounded_rectangle( (0, 0, target_w - 1, total_h - 1), radius=outer_radius, fill=(43, 46, 52, 255), outline=(173, 179, 189, 255), width=max(3, target_w // 420), ) draw.rounded_rectangle( (outer, outer, target_w - outer - 1, total_h - outer - 1), radius=outer_radius - outer // 3, fill=(3, 4, 7, 255), outline=(79, 84, 94, 255), width=max(2, target_w // 600), ) screen = shot.convert("RGB").resize((screen_w, screen_h), Image.Resampling.LANCZOS) inset = outer + bezel screen_radius = round(target_w * 0.020) ipad.paste(screen, (inset, inset), rounded_mask(screen.size, screen_radius)) # A landscape iPad has a small camera on the center of the long top edge, # never an iPhone-style Dynamic Island. camera_r = max(5, round(target_w * 0.0042)) camera_x = target_w // 2 camera_y = outer + bezel // 2 draw.ellipse( ( camera_x - camera_r, camera_y - camera_r, camera_x + camera_r, camera_y + camera_r, ), fill=(5, 8, 13, 255), outline=(32, 52, 75, 255), width=2, ) draw.ellipse( ( camera_x - camera_r // 3, camera_y - camera_r // 3, camera_x + camera_r // 3, camera_y + camera_r // 3, ), fill=(31, 60, 83, 255), ) return ipad def elevated( ipad: Image.Image, angle: float, glow_color: tuple[int, int, int] ) -> Image.Image: rotated = ipad.rotate(angle, expand=True, resample=Image.Resampling.BICUBIC) pad = 68 stage = Image.new( "RGBA", (rotated.width + pad * 2, rotated.height + pad * 2), (0, 0, 0, 0), ) alpha = rotated.getchannel("A") shadow = Image.new("RGBA", rotated.size, (0, 0, 0, 255)) shadow.putalpha(alpha.point(lambda value: round(value * 0.66))) stage.alpha_composite(shadow, (pad + 12, pad + 36)) stage = stage.filter(ImageFilter.GaussianBlur(34)) halo = Image.new("RGBA", rotated.size, glow_color + (255,)) halo.putalpha(alpha.point(lambda value: round(value * 0.20))) stage.alpha_composite(halo, (pad - 4, pad + 8)) stage.alpha_composite(rotated, (pad, pad)) return stage PLACEMENTS = [ ("01_home.png", 1690, -2.2, BLUE), ("02_evolution.png", 1690, 2.2, GREEN), ("03_allocation.png", 1690, -1.8, BLUE), ("04_performance.png", 1690, 2.0, GREEN), ("05_settings.png", 1690, -2.0, BLUE), ] def render( specs: list[dict], output_dir: Path, base_scene: Image.Image ) -> list[Path]: output_dir.mkdir(parents=True, exist_ok=True) canvas = base_scene.copy() for panel, spec in enumerate(specs): draw_campaign_copy(canvas, panel, spec) for panel, (source, target_w, angle, halo) in enumerate(PLACEMENTS): with Image.open(RAW / source) as shot: staged_ipad = elevated(build_ipad(shot, target_w), angle, halo) panel_left = panel * PANEL_W x = panel_left + (PANEL_W - staged_ipad.width) // 2 y = HEIGHT - staged_ipad.height - 14 if x < panel_left or x + staged_ipad.width > panel_left + PANEL_W or y < 0: raise RuntimeError(f"iPad placement escapes panel {panel + 1}") canvas.alpha_composite(staged_ipad, (x, y)) rgb = canvas.convert("RGB") outputs: list[Path] = [] for panel in range(PANELS): path = output_dir / f"portfolio_journal_{panel + 1:02d}.png" image = rgb.crop( (panel * PANEL_W, 0, (panel + 1) * PANEL_W, HEIGHT) ) image.save(path, "PNG", optimize=True) outputs.append(path) preview = output_dir / "_preview.png" preview_w = PANEL_W preview_h = round(HEIGHT * preview_w / WIDTH) rgb.resize((preview_w, preview_h), Image.Resampling.LANCZOS).save( preview, "PNG", optimize=True ) outputs.append(preview) return outputs def verify(outputs: list[Path]) -> None: for path in outputs: with Image.open(path) as image: image.load() expected = ( (PANEL_W, HEIGHT) if path.name != "_preview.png" else (PANEL_W, round(HEIGHT * PANEL_W / WIDTH)) ) if image.size != expected: raise RuntimeError(f"{path}: expected {expected}, got {image.size}") print(f"{path} {image.width}x{image.height}") def main() -> None: base_scene = gradient_background() draw_growth_thread(base_scene) locale_specs = [("en", DEFAULT_ENGLISH_SPECS), *LOCALIZED_SPECS.items()] for locale, specs in locale_specs: suffix = "" if locale == "en" else f"_{locale}" output_dir = ROOT / "build/screenshots" / f"pano_codex_ipad{suffix}" verify(render(specs, output_dir, base_scene)) if __name__ == "__main__": main()