onlayer on show/hide/call screen and `camera <layer>` reach the player, `perspective` is an ATL property now, RotateMatrix/OffsetMatrix/ScaleMatrix, Text.set_text and renpy.fetch are accepted. The text-effect modules of That New Teacher are no longer skipped (only the shader-based wave tag is). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
149 lines
4.9 KiB
Python
149 lines
4.9 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", "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}
|