From 937bc5896aadb227d2b4cd443503e3fce0266802 Mon Sep 17 00:00:00 2001 From: Sashank Thupukari Date: Mon, 24 Aug 2026 23:45:41 +0000 Subject: [PATCH] Add tweetshot.py, the X composer renderer for the #tweets automation The Tweet mockups automation curls this at run time and pipes each Slack message through it, so it lives here rather than inside the automation's instructions, which are capped at 8 KB. --- tweetshot.py | 241 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 tweetshot.py diff --git a/tweetshot.py b/tweetshot.py new file mode 100644 index 0000000..41f9559 --- /dev/null +++ b/tweetshot.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +"""Render a message as a screenshot of the X (Twitter) composer. + +Fetched at run time by the "Tweet mockups" Indent automation, which replies to every message in +Slack #tweets with the message rendered as a tweet: + + curl -fsSL https://assets.indent.com/tweetshot.py -o /tmp/tweetshot.py + python3 /tmp/tweetshot.py --name sashank --handle sthupukari \ + --avatar "https://unavatar.io/x/sthupukari?fallback=false" \ + --text "message text" --out /tmp/tweet.png + +Geometry, type and colours are matched to the original mockup: 642 css px wide at 2x device scale, +40 px avatar, Inter Tight at 16.7 px on a 20 px line. Needs google-chrome, Pillow and numpy. +""" +import argparse, base64, html, os, re, subprocess, sys, tempfile, urllib.parse, urllib.request + +FONT_CACHE = os.path.join(tempfile.gettempdir(), "tweetshot-fontface.css") + +CARD_W = 642 # css px; screenshotted at 2x -> 1284 px wide +PAD_L, PAD_T, PAD_R, PAD_B = 27, 21, 50, 25 +AVATAR, GAP = 40, 8 +NAME_SIZE = 16.2 +HANDLE_SIZE = 16.7 +TEXT_SIZE = 16.7 +LINE_HEIGHT = 20 +TRACKING = -0.2 +NAME_GAP_BOTTOM = 2 +ICONS_GAP = 5 + +BLUE = "#4897ee" +HANDLE_COLOR = "#606c79" +BADGE_COLOR = "#4d9fec" +FALLBACK = "'Liberation Sans','Helvetica Neue',Helvetica,Arial,sans-serif" + +BADGE_SVG = ( + '" % BADGE_COLOR +) + +ICONS_URI = "data:image/png;base64," + ( + "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" + "RK5CYII=" +) + +UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36" + + +def fetch(url, timeout=45): + return urllib.request.urlopen( + urllib.request.Request(url, headers={"User-Agent": UA}), timeout=timeout + ).read() + + +def font_css(text=""): + """Google-hosted Inter Tight (+ any emoji the text needs), inlined as data URIs.""" + css_parts = [] + if os.path.exists(FONT_CACHE): + css_parts.append(open(FONT_CACHE).read()) + else: + block = inline_faces("https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;700&display=swap", + latin_only=True) + open(FONT_CACHE, "w").write(block) + css_parts.append(block) + glyphs = "".join(sorted({c for c in text if ord(c) > 0x2000 and not c.isspace()})) + if glyphs: + try: + css_parts.append(inline_faces( + "https://fonts.googleapis.com/css2?family=Noto+Color+Emoji&text=" + + urllib.parse.quote(glyphs), latin_only=False)) + except Exception as exc: + print("emoji font unavailable: %s" % exc, file=sys.stderr) + return "\n".join(css_parts) + + +def inline_faces(css_url, latin_only): + css = fetch(css_url).decode() + out = [] + for block in re.findall(r"@font-face\s*\{[^}]*\}", css): + rng = re.search(r"unicode-range:\s*([^;]+);", block) + if latin_only and rng and not re.match(r"\s*U\+0{0,3}0-|\s*U\+0100-02BA", rng.group(1)): + continue + url = re.search(r"url\((https://[^)]+)\)", block) + if not url: + continue + blob = base64.b64encode(fetch(url.group(1))).decode() + out.append(block.replace(url.group(1), "data:font/woff2;base64," + blob)) + return "\n".join(out) + + +def slack_to_text(raw): + """Slack markup -> the plain text a reader sees.""" + t = raw + t = re.sub(r"<@[UWB][A-Z0-9]+\|([^>]+)>", r"@\1", t) + t = re.sub(r"<@[UWB][A-Z0-9]+>", "", t) + t = re.sub(r"<#C[A-Z0-9]+\|([^>]+)>", r"#\1", t) + t = re.sub(r"]*>", r"@\1", t) + t = re.sub(r"<((?:https?|mailto):[^|>]+)\|([^>]+)>", r"\2", t) + t = re.sub(r"<((?:https?|mailto):[^>]+)>", r"\1", t) + t = t.replace("&", "&").replace("<", "<").replace(">", ">") + t = re.sub(r"[ \t]+", " ", t) + return t.strip() + + +def text_html(text): + out, token = [], re.compile(r"(https?://\S+|[@#][A-Za-z0-9_]+)") + for part in token.split(text): + if not part: + continue + if token.fullmatch(part): + out.append('%s' % html.escape(part)) + else: + out.append(html.escape(part).replace("\n", "
")) + return "".join(out) + + +def data_uri(src): + blob = fetch(src) if src.startswith("http") else open(src, "rb").read() + kind = "png" if blob[:4] == b"\x89PNG" else "jpeg" + return "data:image/%s;base64,%s" % (kind, base64.b64encode(blob).decode()) + + +def avatar_uri(candidates, name): + """First candidate URL/path that resolves; otherwise an initial-on-gradient circle.""" + for src in candidates: + if not src: + continue + try: + return data_uri(src) + except Exception as exc: + print("avatar %s unavailable: %s" % (src, exc), file=sys.stderr) + letter = html.escape((name.strip()[:1] or "?").upper()) + hue = sum(ord(c) for c in name) % 360 + svg = ( + "" + "" + "%s" % (hue, letter) + ) + return "data:image/svg+xml;base64," + base64.b64encode(svg.encode()).decode() + + +def build_html(name, handle, avatar, text, icons, verified=True): + return f""" +
+ +
+
{html.escape(name)}{BADGE_SVG if verified else ''}@{html.escape(handle)}
+
{text_html(text)}
+
+
+
""" + + +def screenshot(doc, out_path): + from PIL import Image + import numpy as np + with tempfile.TemporaryDirectory() as td: + page, shot = os.path.join(td, "p.html"), os.path.join(td, "s.png") + open(page, "w").write(doc) + subprocess.run( + ["google-chrome", "--headless", "--no-sandbox", "--disable-gpu", + "--hide-scrollbars", "--force-device-scale-factor=2", + "--window-size=%d,2400" % (CARD_W + 40), "--virtual-time-budget=10000", + "--screenshot=" + shot, "file://" + page], + check=True, capture_output=True, timeout=180) + a = np.array(Image.open(shot).convert("RGB")) + keep = ~((a[:, :, 0] > 240) & (a[:, :, 1] < 40) & (a[:, :, 2] > 240)) + rows, cols = np.where(keep.any(axis=1))[0], np.where(keep.any(axis=0))[0] + Image.open(shot).convert("RGB").crop( + (cols.min(), rows.min(), cols.max() + 1, rows.max() + 1)).save(out_path) + return out_path + + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--name", required=True) + p.add_argument("--handle", required=True) + p.add_argument("--avatar", required=True, + help="comma-separated candidate image URLs/paths, tried in order") + p.add_argument("--text", required=True) + p.add_argument("--out", required=True) + p.add_argument("--no-badge", action="store_true") + a = p.parse_args() + doc = build_html(a.name, a.handle.lstrip("@"), avatar_uri(a.avatar.split(","), a.name), + slack_to_text(a.text), ICONS_URI, not a.no_badge) + print(screenshot(doc, a.out)) + + +if __name__ == "__main__": + main()