From 1a447302c86181dc64f1ae8cb2465b1c30429d33 Mon Sep 17 00:00:00 2001 From: kkkkikun Date: Sun, 11 Oct 2026 18:43:05 +0800 Subject: [PATCH 1/2] =?UTF-8?q?tools:=20add=20snap-to-png.py=20=E2=80=94?= =?UTF-8?q?=20render=20a=20running=20app's=20/snap=20widget=20tree=20witho?= =?UTF-8?q?ut=20a=20display?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On headless machines (CI, WSL2, remote boxes), tools/octo shot cannot grab host pixels. This tool renders the live /snap widget tree (labels, buttons, text inputs at real positions with real text) to a PNG for review and iteration — honestly labeled as a redraw, not host pixels. Battle-tested through a full hackathon project: ~30 regression rounds and all submission screenshots were produced with an earlier app-specific version. Usage: python3 tools/snap-to-png.py --port 8141 --out shot.png --- README.md | 1 + tools/snap-to-png.py | 118 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 tools/snap-to-png.py diff --git a/README.md b/README.md index f604e7dd..d39e2355 100644 --- a/README.md +++ b/README.md @@ -660,6 +660,7 @@ public-catalog sample installation and update passed on macOS; see the | `tools/setup-native.py` | Prepares the pinned OctoScript-Makepad runtime ([native-runtime.lock.json](native-runtime.lock.json)) beside this repository | | `tools/image-to-appcard-flow.sh`, `tools/beauty-pipeline.sh`, `tools/beauty-studio.sh` | Entry points for the image-to-card and kit pipelines | | `tools/check-links.py` | Checks that relative Markdown links resolve (run in CI) | +| `tools/snap-to-png.py` | Renders a running app's live `/snap` widget tree to a PNG (for review/iteration on machines without a display; a redraw, not host pixels) | ## Examples diff --git a/tools/snap-to-png.py b/tools/snap-to-png.py new file mode 100644 index 00000000..3b9ad51b --- /dev/null +++ b/tools/snap-to-png.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Render a running script app's `/snap` widget tree to a PNG (no display needed). + +On machines without a graphical session (headless CI, WSL2, remote boxes), +`tools/octo shot` cannot grab host pixels — the render backend has no frames +to read back. Review and iteration still need *some* picture of the running +app. This tool fills that gap honestly: it fetches the **live `/snap` widget +tree** from the running card-host and redraws what the tree says — labels, +buttons, text inputs at their real positions with their real text. + +This is NOT a screenshot of host pixels. Text, hierarchy, positions and state +come from the running app; the painting itself is neutral. Anything the +virtualized list has scrolled out of the viewport is (correctly) absent, which +also makes the tool useful as a "what is on screen right now" probe. + +Usage: + python3 tools/snap-to-png.py --port 8141 --out shot.png [--width 412] [--height 892] + python3 tools/snap-to-png.py --port 8141 --out shot.png --note "caption text" + +Requires: Pillow (`pip install pillow`). CJK fonts are picked from the usual +system paths when available; text falls back to the default bitmap font. +""" + +import argparse +import json +import urllib.request + +from PIL import Image, ImageDraw, ImageFont + +BG = (250, 248, 244) +INK = (43, 39, 34) +SECOND = (110, 103, 94) +ACCENT = (63, 81, 181) # neutral accent; apps may theme differently +CARD = (241, 236, 228) +LINE = (223, 216, 205) + +FONT_PATHS = [ + "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", + "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", +] + + +def load_font(size): + for p in FONT_PATHS: + try: + return ImageFont.truetype(p, size) + except OSError: + continue + return ImageFont.load_default() + + +def fetch(port, path, timeout=10): + with urllib.request.urlopen(f"http://127.0.0.1:{port}{path}", timeout=timeout) as r: + return json.loads(r.read()) + + +def snap_widgets(port): + return fetch(port, "/snap")["s"] + + +def main(): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--port", type=int, required=True, help="card-host remote port") + ap.add_argument("--out", required=True, help="output PNG path") + ap.add_argument("--width", type=int, default=412, help="canvas width (layout points)") + ap.add_argument("--height", type=int, default=892, help="canvas height (layout points)") + ap.add_argument("--note", default="", help="caption drawn under the top bar") + args = ap.parse_args() + + widgets = [w for w in snap_widgets(args.port) + if w.get("ty") not in ("Splash",) and "Card host [remote]" not in (w.get("t") or "")] + widgets.sort(key=lambda w: (w.get("r") or [0, 0])[1]) + + img = Image.new("RGB", (args.width, args.height), BG) + d = ImageDraw.Draw(img) + d.rectangle([0, 0, args.width, 6], fill=ACCENT) + f_title = load_font(20) + f_body = load_font(14) + f_small = load_font(12) + + y_top = 30 + if args.note: + d.text((12, 10), args.note, fill=SECOND, font=f_small) + + for w in widgets: + r = w.get("r") or [0, 0, 0, 0] + x, y, ww, hh = r + if ww <= 0 or hh <= 0 or y < y_top or y > args.height or x < -4: + continue + ty, text = w.get("ty"), (w.get("t") or "").strip() + wid = w.get("i") + if not text and ty != "TextInput": + continue + if ty == "Button": + d.rounded_rectangle([x, y, x + ww, y + hh], radius=10, fill=CARD) + d.text((x + 6, y + max(2, hh // 2 - 9)), text[:24], fill=ACCENT, font=f_body) + elif ty == "TextInput": + d.rounded_rectangle([x, y, x + ww, y + hh], radius=10, + fill=(255, 255, 255), outline=LINE) + val = str(w.get("val") or "") + shown = val if val else text + d.text((x + 8, y + max(2, hh // 2 - 9)), shown[:40], + fill=INK if val else SECOND, font=f_body) + elif wid in ("hint",) or text.startswith(("AI", "已", "把", "(")): + d.text((x + 4, y), text[:60], fill=SECOND, font=f_small) + elif y < y_top + 60 and hh >= 24: + d.text((x + 4, y), text[:40], fill=INK, font=f_title) + else: + d.text((x + 4, y), text[:60], fill=INK, font=f_body) + + img.save(args.out) + print(f"wrote {args.out} from live /snap of port {args.port} " + f"({len(widgets)} widgets; widget-tree redraw, not host pixels)") + + +if __name__ == "__main__": + main() From fcb4e2a471e117329ee0f574f33218b8443f054a Mon Sep 17 00:00:00 2001 From: kkkkikun Date: Tue, 13 Oct 2026 14:47:17 +0800 Subject: [PATCH 2/2] =?UTF-8?q?tools:=20snap-to-png=20=E2=80=94=20address?= =?UTF-8?q?=20review=20(security,=20neutrality,=20self-labelling)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review concerns: 1. Password inputs no longer leak cleartext: the raw snapshot value (val) is never painted — text inputs draw the snapshot's display text (t), which is the • mask for passwords. val now serves only as an is-it-empty flag for ink color. 2. App-specific text heuristics removed: no more startswith prefixes or widget-id demotion carried over from the ancestor project; styling is uniform (title rule + body), per the neutral-palette claim. 3. The artifact labels itself: a permanent bottom band reads "widget-tree redraw · not host pixels", drawn last so content cannot overpaint it — the honesty claim now travels with the PNG. Smaller notes: - README.zh-CN.md tools table gained the mirrored row (kept in sync). - FONT_PATHS covers macOS (PingFang/STHeiti) and Windows (msyh/simhei) so CJK no longer falls back to tofu there. - Failure modes print one-line actionable hints (unreachable port / unexpected payload) and --out parents are mkdir'd. - The output line now counts drawn widgets ("N drawn of M"). - Truncation is width-based with an ellipsis, and clipped narrow labels no longer paint their full text. Verified: password-shaped payload renders the mask (not the secret), the four formerly-demoted prefixes render as normal body text, python -m unittest discover -s tools (48) and tools/check-links.py pass, and a live card-host render redraws correctly. --- README.zh-CN.md | 1 + tools/snap-to-png.py | 86 ++++++++++++++++++++++++++++++++++---------- 2 files changed, 69 insertions(+), 18 deletions(-) diff --git a/README.zh-CN.md b/README.zh-CN.md index 3fce95a8..4d45c0e6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -365,6 +365,7 @@ curl -s 127.0.0.1:8161/quit; curl -s 127.0.0.1:8162/quit | `tools/setup-native.py` | 在本仓库旁准备锁定版本的 OctoScript-Makepad 运行时([native-runtime.lock.json](native-runtime.lock.json)) | | `tools/image-to-appcard-flow.sh`、`tools/beauty-pipeline.sh`、`tools/beauty-studio.sh` | image-to-card 与套件流水线的入口 | | `tools/check-links.py` | 检查 Markdown 相对链接能否解析(CI 中运行) | +| `tools/snap-to-png.py` | 把运行中应用的实时 `/snap` 控件树渲染为 PNG(供无显示器的机器评审/迭代;这是重绘,不是宿主像素) | ## 示例 diff --git a/tools/snap-to-png.py b/tools/snap-to-png.py index 3b9ad51b..b9008fee 100644 --- a/tools/snap-to-png.py +++ b/tools/snap-to-png.py @@ -13,16 +13,27 @@ virtualized list has scrolled out of the viewport is (correctly) absent, which also makes the tool useful as a "what is on screen right now" probe. +The artifact labels itself: every PNG carries a permanent bottom band reading +"widget-tree redraw · not host pixels". Screenshots are review evidence in this +repo's flow (docs/PUBLISHING.md), so the honesty label must travel with the +file — not just live in this docstring, the console output and the README. + +Text inputs are drawn from the snapshot's display text only (`t`); the raw +value (`val`) is never painted, so a password field shows its `•••` mask. + Usage: python3 tools/snap-to-png.py --port 8141 --out shot.png [--width 412] [--height 892] python3 tools/snap-to-png.py --port 8141 --out shot.png --note "caption text" Requires: Pillow (`pip install pillow`). CJK fonts are picked from the usual -system paths when available; text falls back to the default bitmap font. +Linux/macOS/Windows system paths when available; text falls back to the +default bitmap font. """ import argparse import json +import os +import sys import urllib.request from PIL import Image, ImageDraw, ImageFont @@ -37,9 +48,16 @@ FONT_PATHS = [ "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", + "/System/Library/Fonts/PingFang.ttc", # macOS + "/System/Library/Fonts/STHeiti Light.ttc", # macOS fallback + "C:\\Windows\\Fonts\\msyh.ttc", # Windows + "C:\\Windows\\Fonts\\simhei.ttf", # Windows fallback "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", ] +WATERMARK = "widget-tree redraw · not host pixels" +BAND_H = 20 # permanent bottom band; content at or below it is skipped + def load_font(size): for p in FONT_PATHS: @@ -50,13 +68,34 @@ def load_font(size): return ImageFont.load_default() -def fetch(port, path, timeout=10): - with urllib.request.urlopen(f"http://127.0.0.1:{port}{path}", timeout=timeout) as r: - return json.loads(r.read()) +def fail(msg): + """One-line actionable error, like the octo tool family (no tracebacks).""" + print(f"snap-to-png: {msg}", file=sys.stderr) + sys.exit(2) def snap_widgets(port): - return fetch(port, "/snap")["s"] + try: + with urllib.request.urlopen(f"http://127.0.0.1:{port}/snap", timeout=10) as r: + payload = json.loads(r.read()) + except Exception as e: + fail(f"cannot read /snap from 127.0.0.1:{port} " + f"({e.__class__.__name__}: {e}) — is card-host running with --remote {port}?") + if not isinstance(payload, dict) or not isinstance(payload.get("s"), list): + fail(f"unexpected reply from port {port} (no \"s\" widget list) — " + f"is a card-host listening on it?") + return payload["s"] + + +def fit_text(d, text, font, max_w): + """Width-based truncation with an ellipsis (never a silent hard cut).""" + if max_w <= 0: + return "" + if d.textlength(text, font=font) <= max_w: + return text + while text and d.textlength(text + "…", font=font) > max_w: + text = text[:-1] + return text + "…" if text else "" def main(): @@ -80,38 +119,49 @@ def main(): f_small = load_font(12) y_top = 30 + y_floor = args.height - BAND_H if args.note: - d.text((12, 10), args.note, fill=SECOND, font=f_small) + d.text((12, 10), fit_text(d, args.note, f_small, args.width - 24), fill=SECOND, font=f_small) + drawn = 0 for w in widgets: r = w.get("r") or [0, 0, 0, 0] x, y, ww, hh = r - if ww <= 0 or hh <= 0 or y < y_top or y > args.height or x < -4: + if ww <= 0 or hh <= 0 or y < y_top or y >= y_floor or x < -4: continue ty, text = w.get("ty"), (w.get("t") or "").strip() - wid = w.get("i") if not text and ty != "TextInput": continue if ty == "Button": d.rounded_rectangle([x, y, x + ww, y + hh], radius=10, fill=CARD) - d.text((x + 6, y + max(2, hh // 2 - 9)), text[:24], fill=ACCENT, font=f_body) + d.text((x + 6, y + max(2, hh // 2 - 9)), fit_text(d, text, f_body, ww - 12), + fill=ACCENT, font=f_body) elif ty == "TextInput": d.rounded_rectangle([x, y, x + ww, y + hh], radius=10, fill=(255, 255, 255), outline=LINE) - val = str(w.get("val") or "") - shown = val if val else text - d.text((x + 8, y + max(2, hh // 2 - 9)), shown[:40], - fill=INK if val else SECOND, font=f_body) - elif wid in ("hint",) or text.startswith(("AI", "已", "把", "(")): - d.text((x + 4, y), text[:60], fill=SECOND, font=f_small) + # The snapshot's `t` is the widget's *display* text + # (widget_tree.rs: text = display_text()) — for a password input + # that is the `•` mask. `val` holds the raw text and is never + # drawn; it serves only as an "is it empty?" flag for the ink. + filled = bool(str(w.get("val") or "").strip()) + d.text((x + 8, y + max(2, hh // 2 - 9)), fit_text(d, text, f_body, ww - 16), + fill=INK if filled else SECOND, font=f_body) elif y < y_top + 60 and hh >= 24: - d.text((x + 4, y), text[:40], fill=INK, font=f_title) + d.text((x + 4, y), fit_text(d, text, f_title, args.width - x - 8), fill=INK, font=f_title) else: - d.text((x + 4, y), text[:60], fill=INK, font=f_body) + # Fit to the widget's own rect: a label the host clipped to a + # narrow strip should not paint its full text here either. + d.text((x + 4, y), fit_text(d, text, f_body, ww), fill=INK, font=f_body) + drawn += 1 + + # Watermark last, so no content can overpaint it. + d.rectangle([0, y_floor, args.width, args.height], fill=CARD) + d.text((8, y_floor + 4), WATERMARK, fill=SECOND, font=f_small) + os.makedirs(os.path.dirname(os.path.abspath(args.out)), exist_ok=True) img.save(args.out) print(f"wrote {args.out} from live /snap of port {args.port} " - f"({len(widgets)} widgets; widget-tree redraw, not host pixels)") + f"({drawn} drawn of {len(widgets)} widgets; {WATERMARK})") if __name__ == "__main__":