render.py (7207B)
1 #!/usr/bin/env python3 2 """Render the Penpot Google Play layouts with supplied screenshots or empty screens.""" 3 4 import argparse 5 import base64 6 import copy 7 import html 8 import io 9 import json 10 import os 11 from pathlib import Path 12 import shutil 13 import subprocess 14 import tempfile 15 import xml.etree.ElementTree as ET 16 17 from PIL import Image, ImageFont 18 19 ROOT = Path(__file__).resolve().parent 20 SVG = "http://www.w3.org/2000/svg" 21 XLINK = "http://www.w3.org/1999/xlink" 22 ET.register_namespace("", SVG) 23 ET.register_namespace("xlink", XLINK) 24 FONT = ROOT / "assets/fonts/Raleway-Bold.ttf" 25 TEMPLATES = ROOT / "assets/templates" 26 PROFILES = {"phone": "phone-portrait", "tablet": "tablet-landscape"} 27 28 29 def element(root, element_id): 30 return next(node for node in root.iter() if node.get("id") == element_id) 31 32 33 def make_svg(profile, card, screenshot): 34 root = ET.parse(TEMPLATES / f"{profile}.svg").getroot() 35 logo = ET.parse(TEMPLATES / "taler-pos-white.svg").getroot() 36 element(root, "logo").extend(copy.deepcopy(list(logo))) 37 38 # Embed the exact font for standalone browser previews; librsvg uses the same 39 # file through the private Fontconfig configuration created below. 40 style = ET.SubElement(root, f"{{{SVG}}}style") 41 encoded_font = base64.b64encode(FONT.read_bytes()).decode("ascii") 42 style.text = ( 43 "@font-face{font-family:Raleway;font-style:normal;font-weight:700;" 44 f"src:url(data:font/ttf;base64,{encoded_font}) format('truetype');}}" 45 ) 46 heading = element(root, "headline") 47 x, y, width, height, size = [float(heading.get("data-" + key)) for key in 48 ("x", "y", "width", "height", "font-size")] 49 lines = card["headline"].splitlines() 50 if profile == "tablet": 51 lines = [" ".join(lines)] 52 font = ImageFont.truetype(str(FONT), round(size)) 53 leading = size * float(heading.get("data-line-height")) 54 if len(lines) * leading > height or any(font.getlength(line) > width for line in lines): 55 raise ValueError(f"Headline does not fit the {profile} template: {card['headline']!r}") 56 ascent, descent = font.getmetrics() 57 baseline = y + (height - len(lines) * leading) / 2 + (leading - ascent - descent) / 2 + ascent 58 for index, line in enumerate(lines): 59 text = ET.SubElement(heading, f"{{{SVG}}}text", { 60 "x": f"{x + width / 2:g}", "y": f"{baseline + index * leading:g}", 61 "text-anchor": "middle", "font-family": "Raleway", "font-weight": "700", 62 "font-size": f"{size:g}", "fill": "#FFFFFF", 63 }) 64 text.text = line 65 66 screen = element(root, "screen-background") 67 screen.set("fill", "#1C1B1F" if card["theme"] == "dark" else "#FFFFFF") 68 if screenshot: 69 with Image.open(screenshot) as image: 70 if (image.width > image.height) != (profile == "tablet"): 71 raise ValueError(f"Wrong screenshot orientation for {profile}: {screenshot}") 72 # PNG inside the SVG keeps the supplied image lossless. The image is 73 # fitted inside the screen, never stretched or cropped to its bounds. 74 stream = io.BytesIO() 75 image.convert("RGB").save(stream, format="PNG") 76 attrs = {key: screen.get(key) for key in ("x", "y", "width", "height")} 77 attrs.update({"preserveAspectRatio": "xMidYMid meet", 78 f"{{{XLINK}}}href": "data:image/png;base64," + 79 base64.b64encode(stream.getvalue()).decode("ascii")}) 80 ET.SubElement(element(root, "screen"), f"{{{SVG}}}image", attrs) 81 return ET.tostring(root, encoding="utf-8", xml_declaration=True) 82 83 84 def main(): 85 cards = json.loads((ROOT / "cards.json").read_text()) 86 parser = argparse.ArgumentParser(description=__doc__) 87 source = parser.add_mutually_exclusive_group(required=True) 88 source.add_argument("--screenshots", type=Path, help="Capture root containing en/phone-portrait and en/tablet-landscape") 89 source.add_argument("--screenshot", type=Path, help="One image; requires one --profile and --card") 90 source.add_argument("--blank", action="store_true", help="Render all designs without any screenshots") 91 parser.add_argument("--profile", choices=["both", *PROFILES], default="both") 92 parser.add_argument("--card", choices=cards, action="append", help="Only render these cards (repeatable)") 93 parser.add_argument("--out", type=Path, default=ROOT.parents[1] / "build/play-store-artwork") 94 output_format = parser.add_mutually_exclusive_group() 95 output_format.add_argument("--png-only", action="store_true", help="Write only PNGs, using temporary SVG intermediates") 96 output_format.add_argument("--svg-only", action="store_true", help="Generate self-contained SVGs without requiring librsvg") 97 args = parser.parse_args() 98 profiles = list(PROFILES) if args.profile == "both" else [args.profile] 99 selected = args.card or list(cards) 100 if args.screenshot and (len(profiles) != 1 or len(selected) != 1): 101 parser.error("--screenshot requires exactly one --profile and --card") 102 converter = shutil.which("rsvg-convert") 103 if not args.svg_only and not converter: 104 parser.error("rsvg-convert is required for PNG output (see README), or use --svg-only") 105 106 # Resolve all inputs before writing outputs, to catch incomplete captures early. 107 jobs = [] 108 for profile in profiles: 109 for name in selected: 110 card = cards[name] 111 path = args.screenshot 112 if args.screenshots: 113 path = args.screenshots / "en" / PROFILES[profile] / card["theme"] / card["screenshot"] 114 if path and not path.is_file(): 115 parser.error(f"Screenshot not found: {path}") 116 jobs.append((profile, name, card, path)) 117 118 with tempfile.TemporaryDirectory(prefix="taler-play-fonts-") as temp: 119 config = Path(temp) / "fonts.conf" 120 config.write_text("<?xml version='1.0'?><!DOCTYPE fontconfig SYSTEM 'fonts.dtd'>" 121 "<fontconfig><dir>" + html.escape(str(FONT.parent)) + 122 "</dir><cachedir>" + html.escape(temp) + "</cachedir></fontconfig>") 123 env = dict(os.environ, FONTCONFIG_FILE=str(config)) 124 for profile, name, card, path in jobs: 125 output = args.out / profile 126 output.mkdir(parents=True, exist_ok=True) 127 svg = (Path(temp) if args.png_only else output) / f"{name}.svg" 128 svg.write_bytes(make_svg(profile, card, path)) 129 if not args.svg_only: 130 png = output / f"{name}.png" 131 subprocess.run([converter, "--output", str(png), str(svg)], env=env, check=True) 132 with Image.open(png) as rendered: 133 expected = (1080, 1920) if profile == "phone" else (2560, 1440) 134 if rendered.size != expected: 135 raise ValueError(f"Unexpected output size: {rendered.size}") 136 rendered.convert("RGB").save(png) # Play Store requires no alpha. 137 if png.stat().st_size > 8 * 1024 * 1024: 138 raise ValueError(f"Output exceeds 8 MB: {png}") 139 print(f"Rendered {profile}/{name}") 140 141 142 143 if __name__ == "__main__": 144 main()