Files
pgames-manager/backend/app/converter/rpy_helpers.py
T
mrleo1nidandClaude Sonnet 5.5 91b1d808ce feat: ATL keeps xpan/ypan/xtile/ytile, 3D rotations, zpos, blend, fit and friends instead of dropping them
These properties were treated as GPU hints and silently discarded. The converter now carries xpan, ypan,
xtile, ytile, xrotate, yrotate, zrotate, zpos, zzoom, blend, additive, transform_anchor, rotate_pad,
matrixanchor, matrixtransform, fit, maxsize and crop_relative into the timelines.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 01:45:01 +03:00

149 lines
4.8 KiB
Python

"""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"}
_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}