"""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")