"""Module-level helpers of the Ren'Py parser (Python snippets, ATL props, constants).""" import ast import re import warnings from typing import Any from app.converter.pyexpr import Expr, python_ast Node = dict[str, Any] POSITION_PROPS = {"xpos", "ypos", "xanchor", "yanchor", "xalign", "yalign", "xcenter", "ycenter", "zoom", "xzoom", "yzoom", "alpha", "rotate", "xoffset", "yoffset", "align", "pos", "anchor", "xysize", "size", "blur", "matrixcolor", "xycenter", "xsize", "ysize", "crop", "xpan", "ypan", "xtile", "ytile", "xrotate", "yrotate", "zrotate", "zpos", "zzoom", "blend", "additive", "transform_anchor", "rotate_pad", "matrixanchor", "matrixtransform", "fit", "maxsize", "crop_relative", "perspective"} _SHOW_KEYWORDS = {"at", "as", "behind", "onlayer", "with", "zorder"} _NUMBER_LINE = re.compile(r"^\d+(\.\d*)?$|^\.\d+$") _CALL_LINE = re.compile(r"^([A-Za-z_][\w.]*)\s*\(") _DISPLAYABLE_CALLS = {"Null", "Movie", "Solid", "Transform", "Composite", "LiveComposite", "Fixed", "ConditionSwitch", "TransitionConditionSwitch", "Image", "Text", "AlphaMask", "im.Scale", "im.FactorScale", "im.Flip", "im.Composite", "im.Crop", "im.Grayscale", "im.Sepia", "Frame", "Flatten"} _MEDIA_EXTS = {"png", "jpg", "jpeg", "webp", "gif", "bmp", "avif", "ogg", "mp3", "wav", "opus", "flac", "webm", "ogv", "mp4", "mkv", "avi", "m4a"} def _parse_py(source: str, mode: str = "exec") -> ast.AST: # decompiled scripts are full of "\{" in plain strings: don't spam SyntaxWarning with warnings.catch_warnings(): warnings.simplefilter("ignore") return python_ast(source, mode=mode) def _dotted_name(e: Expr) -> str: if e["k"] == "name": return e["id"] if e["k"] == "attr": return f"{_dotted_name(e['o'])}.{e['a']}" return "" def _assign_name(t: ast.AST) -> str | None: if isinstance(t, ast.Name): return t.id if isinstance(t, ast.Attribute): inner = _assign_name(t.value) return f"{inner}.{t.attr}" if inner else None return None def _const(e: Expr | None) -> Any: if e is None: return None if e.get("k") == "const": return e["v"] if e.get("k") in ("tuple", "list"): return [_const(x) for x in e["v"]] if e.get("k") == "unary" and e["op"] == "-": v = _const(e["v"]) return -v if isinstance(v, (int, float)) else None return None def _num(s: str | None) -> float | None: try: return float(s) if s is not None else None except ValueError: return None def _static_props(kw: dict[str, Expr]) -> dict[str, Any]: props = {} for k, v in kw.items(): if k in POSITION_PROPS: val = _const(v) if val is not None: props[k] = val return props def _atl_props(text: str) -> dict[str, Any]: """Extract static `prop value` pairs from one ATL line, ignoring timing.""" tokens = _words(text) props: dict[str, Any] = {} i = 0 while i < len(tokens) - 1: key = tokens[i] if key in POSITION_PROPS: raw = tokens[i + 1] try: value = ast.literal_eval(raw) props[key] = list(value) if isinstance(value, tuple) else value except (ValueError, SyntaxError): if not raw.startswith("(") and i + 2 < len(tokens): # `pos 0.5 0.3` try: props[key] = [ast.literal_eval(raw), ast.literal_eval(tokens[i + 2])] i += 1 except (ValueError, SyntaxError): pass i += 2 else: i += 1 return props def _words(text: str) -> list[str]: """Words split on whitespace and commas outside brackets: `crop (1, 2, 3, 4)` keeps its tuple whole.""" out, cur, depth = [], [], 0 for ch in text: if ch in "([{": depth += 1 elif ch in ")]}": depth -= 1 if (ch.isspace() or ch == ",") and depth == 0: if cur: out.append("".join(cur)) cur = [] else: cur.append(ch) if cur: out.append("".join(cur)) return out def _split_top(source: str) -> list[str]: parts, depth, cur = [], 0, [] for ch in source: if ch in "([{": depth += 1 elif ch in ")]}": depth -= 1 if ch == "," and depth == 0: parts.append("".join(cur)) cur = [] else: cur.append(ch) if "".join(cur).strip(): parts.append("".join(cur)) return [p.strip() for p in parts if p.strip()] def _jump_or_call(op: str, target: Expr) -> Node: if target.get("k") == "const": return {"op": op, "target": target["v"]} return {"op": op, "expr": target}