taler-android

Android apps for GNU Taler (wallet, PoS, cashier)
Log | Files | Refs | README | LICENSE

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()