Improve Android app UI and controller flows
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
"""Scene-v2 validation, legacy upgrade and logical composition. No driver I/O."""
|
||||
from __future__ import annotations
|
||||
import base64
|
||||
import binascii
|
||||
import copy
|
||||
import math
|
||||
from io import BytesIO
|
||||
from uuid import uuid4
|
||||
from PIL import Image
|
||||
from app.display.text_renderer import TextOptions, render_text_layer
|
||||
from app.display.colors import parse_color
|
||||
|
||||
SIDE = 192
|
||||
ORIGIN = 64
|
||||
|
||||
|
||||
def decode_pixels(value):
|
||||
if not isinstance(value, str) or len(value) > 300000:
|
||||
raise ValueError("brush PNG must be bounded base64")
|
||||
try:
|
||||
data = base64.b64decode(value, validate=True)
|
||||
if (data[:8] != b"\x89PNG\r\n\x1a\n" or data[8:16] != b"\x00\x00\x00\rIHDR"
|
||||
or data[24:29] != b"\x08\x06\x00\x00\x00"):
|
||||
raise ValueError("brush PNG must be noninterlaced RGBA8")
|
||||
with Image.open(BytesIO(data)) as image:
|
||||
image.verify()
|
||||
with Image.open(BytesIO(data)) as image:
|
||||
if image.format != "PNG" or image.size != (SIDE, SIDE) or image.mode != "RGBA":
|
||||
raise ValueError("brush PNG must be 192x192 RGBA")
|
||||
image.load()
|
||||
return image.copy()
|
||||
except (OSError, binascii.Error) as exc:
|
||||
raise ValueError("invalid brush PNG") from exc
|
||||
|
||||
|
||||
def encode_pixels(image):
|
||||
out = BytesIO()
|
||||
image.save(out, format="PNG", compress_level=6)
|
||||
return base64.b64encode(out.getvalue()).decode("ascii")
|
||||
|
||||
|
||||
def upgrade_scene(scene):
|
||||
if scene["version"] == 2:
|
||||
return copy.deepcopy(scene)
|
||||
pixels = Image.frombytes("RGB", (64, 64), base64.b64decode(scene["pixelRgb"])).convert("RGBA")
|
||||
canvas = Image.new("RGBA", (SIDE, SIDE))
|
||||
canvas.paste(pixels, (ORIGIN, ORIGIN))
|
||||
layers = [dict(id="layer-1", name="图层1", type="brush", x=0, y=0,
|
||||
background=None, pixelPng=encode_pixels(canvas))]
|
||||
if scene["elements"]:
|
||||
layers.append(dict(id="layer-2", name="图层2", type="text", x=0, y=0,
|
||||
elements=[dict(element, clip=None) for element in scene["elements"]]))
|
||||
return dict(version=2, width=64, height=64, layers=layers)
|
||||
|
||||
|
||||
def validate_v2(value, validate_legacy):
|
||||
if set(value) != {"version", "width", "height", "layers"} or any(type(value[k]) is not int for k in ("version", "width", "height")) or (value["version"], value["width"], value["height"]) != (2, 64, 64):
|
||||
raise ValueError("scene v2 fields or dimensions are invalid")
|
||||
if not isinstance(value["layers"], list) or not value["layers"]:
|
||||
raise ValueError("scene must contain a brush layer")
|
||||
ids, element_ids, layers = set(), set(), []
|
||||
for layer in value["layers"]:
|
||||
layers.append(validate_layer(layer, validate_legacy, ids, element_ids))
|
||||
if not any(layer["type"] == "brush" for layer in layers):
|
||||
raise ValueError("scene must retain at least one brush layer")
|
||||
return dict(version=2, width=64, height=64, layers=layers)
|
||||
|
||||
|
||||
def validate_layer(layer, validate_legacy, ids=None, element_ids=None):
|
||||
ids = ids if ids is not None else set()
|
||||
element_ids = element_ids if element_ids is not None else set()
|
||||
if not isinstance(layer, dict) or layer.get("type") not in {"brush", "text"}:
|
||||
raise ValueError("unsupported layer type")
|
||||
fields = {"id", "name", "type", "x", "y"} | ({"background", "pixelPng"} if layer["type"] == "brush" else {"elements"})
|
||||
if layer["type"] == "brush" and "backgroundColor" in layer:
|
||||
fields.add("backgroundColor")
|
||||
if set(layer) != fields:
|
||||
raise ValueError("layer fields do not match schema")
|
||||
ident = layer["id"]
|
||||
if not isinstance(ident, str) or not ident.strip() or len(ident) > 128 or ident in ids:
|
||||
raise ValueError("layer ids must be unique nonempty strings")
|
||||
ids.add(ident)
|
||||
if not isinstance(layer["name"], str) or not layer["name"].strip() or len(layer["name"]) > 80:
|
||||
raise ValueError("layer name must contain 1..80 characters")
|
||||
if any(type(layer[k]) is not int or not -64 <= layer[k] <= 64 for k in ("x", "y")):
|
||||
raise ValueError("layer offset must be an integer in -64..64")
|
||||
result = copy.deepcopy(layer)
|
||||
if layer["type"] == "brush":
|
||||
decode_pixels(layer["pixelPng"])
|
||||
if "backgroundColor" in layer:
|
||||
color = layer["backgroundColor"]
|
||||
if not isinstance(color, str) or len(color) != 7 or not color.startswith("#"):
|
||||
raise ValueError("backgroundColor must be #RRGGBB")
|
||||
parse_color(color)
|
||||
if layer["background"] is not None:
|
||||
if not isinstance(layer["background"], str) or len(layer["background"]) != 7 or not layer["background"].startswith("#"):
|
||||
raise ValueError("background must be #RRGGBB or null")
|
||||
parse_color(layer["background"])
|
||||
else:
|
||||
if not isinstance(layer["elements"], list):
|
||||
raise ValueError("text elements must be an array")
|
||||
checked = []
|
||||
for element in layer["elements"]:
|
||||
if not isinstance(element, dict) or "clip" not in element:
|
||||
raise ValueError("text clip is required")
|
||||
raw = {k: v for k, v in element.items() if k != "clip"}
|
||||
legacy = dict(version=1, width=64, height=64, pixelRgb=base64.b64encode(bytes(12288)).decode("ascii"), elements=[raw])
|
||||
text = validate_legacy(legacy, strict_schema=True)["elements"][0]
|
||||
if text["id"] in element_ids:
|
||||
raise ValueError("text ids must be globally unique")
|
||||
element_ids.add(text["id"])
|
||||
clip = element["clip"]
|
||||
if clip is not None and (not isinstance(clip, list) or len(clip) != 4 or any(type(n) not in (int, float) or not math.isfinite(n) or abs(n) > 4096 for n in clip) or clip[2] < clip[0] or clip[3] < clip[1]):
|
||||
raise ValueError("text clip must be a finite rectangle or null")
|
||||
checked.append(dict(text, clip=clip))
|
||||
result["elements"] = checked
|
||||
return result
|
||||
|
||||
|
||||
def copy_layer(layer, existing):
|
||||
result = copy.deepcopy(layer)
|
||||
result["id"] = str(uuid4())
|
||||
sequence = 1
|
||||
names = {item["name"] for item in existing}
|
||||
while f"图层{sequence}" in names:
|
||||
sequence += 1
|
||||
result["name"] = f"图层{sequence}"
|
||||
for element in result.get("elements", []):
|
||||
element["id"] = str(uuid4())
|
||||
return result
|
||||
|
||||
|
||||
def render_element(element, font_resolver=None):
|
||||
options = TextOptions(**{k: element[k] for k in ("text", "font", "size", "align")},
|
||||
x=element["x"] + ORIGIN, y=element["y"] + ORIGIN,
|
||||
color=parse_color(element["color"]))
|
||||
image = render_text_layer(options, size=(SIDE, SIDE), font_resolver=font_resolver)
|
||||
if element.get("clip") is not None:
|
||||
# Rectangle is relative to the text anchor, in units of its font size.
|
||||
left, top, right, bottom = element["clip"]
|
||||
pixels = image.load()
|
||||
for y in range(SIDE):
|
||||
for x in range(SIDE):
|
||||
u=(x-ORIGIN-element["x"])/element["size"]
|
||||
v=(y-ORIGIN-element["y"])/element["size"]
|
||||
if not left <= u < right or not top <= v < bottom:
|
||||
pixels[x,y]=(0,0,0,0)
|
||||
return image
|
||||
|
||||
|
||||
def render_v2(scene, font_resolver=None):
|
||||
image = Image.new("RGBA", (SIDE, SIDE), (0, 0, 0, 255))
|
||||
def paint(source, layer):
|
||||
nonlocal image
|
||||
shifted = Image.new("RGBA", (SIDE, SIDE))
|
||||
shifted.paste(source, (layer["x"], layer["y"]))
|
||||
image = Image.alpha_composite(image, shifted)
|
||||
|
||||
for layer in scene["layers"]:
|
||||
if layer["type"] == "brush":
|
||||
if layer["background"]:
|
||||
paint(Image.new("RGBA", (SIDE, SIDE), parse_color(layer["background"]) + (255,)), layer)
|
||||
paint(decode_pixels(layer["pixelPng"]), layer)
|
||||
else:
|
||||
# Match browser source-over rounding after every text element.
|
||||
for element in layer["elements"]:
|
||||
paint(render_element(element, font_resolver), layer)
|
||||
return image.crop((ORIGIN, ORIGIN, ORIGIN+64, ORIGIN+64)).convert("RGB")
|
||||
Reference in New Issue
Block a user