169 lines
7.8 KiB
Python
169 lines
7.8 KiB
Python
"""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")
|