#!/usr/bin/env python3 """Draw the starter scenes: four pictures of everyday places, each with the hotspots a person can choose on it, for the visual scene displays of the AAC reference app (aac-core `scene`, proposed SPEC §2.5). python3 tools/pagesets/scenes/draw.py # writes pictures/*.png and scenes.json Each picture is drawn here: a background of plain shapes (a wall, a table, the sky, grass) and objects from OpenMoji 17.0.0 (CC BY-SA 4.0), the release the app's symbols come from (tools/symbols/build.py pins it by SHA-256 and keeps it in tools/symbols/.cache). A hotspot is where its object is drawn: the box of the object's own strokes, measured by drawing it alone, with a little room round it for a finger. No person is drawn, so the scenes show nobody's skin, gender or age (SPEC §4.3); a partner adds the person's own photos. What it writes is committed, as the sources of the scene page set (build.py beside this file builds the package from them, byte for byte): pictures/.png each picture, 1200 x 900, a 256-colour PNG scenes.json each scene: its name, what it shows, its row of words, and its hotspots in picture pixels Needs Python 3.11+, Pillow, and rsvg-convert (librsvg) to draw the SVG it composes. Drawing again with other versions of those may change the PNGs' bytes; that is a new version of the pictures, and the catalogue is written again (tools/pagesets/catalog.py --write). """ from __future__ import annotations import io import json import re import shutil import subprocess import sys import tempfile import zipfile from pathlib import Path HERE = Path(__file__).resolve().parent AAC_RS = HERE.parents[2] sys.path.insert(0, str(AAC_RS / "tools" / "symbols")) import build as symbols # noqa: E402 (tools/symbols/build.py: the pinned OpenMoji release) from PIL import Image # noqa: E402 W, H = 1200, 900 # Room round an object's strokes that still chooses it, in picture pixels. PAD = 10 # The least width and height of a hotspot, in picture pixels: a seventh of # the picture's width, a fifth of its height. On the smallest screen the app # fits (a 568 x 320 phone on its side, where the picture is about 187 x 140 # CSS pixels beside its words), that is 26 CSS pixels: above WCAG 2.2's # 24-pixel target (aac-web scenes.rs MIN_TARGET_PX; e2e scenes.spec checks # it on every screen of fit.spec). A small thing's hotspot is larger than # the thing, centred on it. MIN_SIDE = 168 # ── the scenes ───────────────────────────────────────────────────────────── # Words are the app's English keys, glossed in every language the app has # (each hotspot word and each word of the row has a gloss in all 44, agreed in # its sense here by a gloss audit: build.py checks). A key may name its sense # in brackets — "cold (water)", "table (dining)" — where some languages say it # with another word than the general key (Japanese つめたい for cold water, not # さむい; Korean 식탁 for the table one eats at); in English it shows the word # alone. `objects`: (word or None for a drawing only, OpenMoji hexcode, x, y, # size). `shapes`: (word, [x, y, w, h], svg) — a hotspot drawn as plain shapes. SKY = "#bfe3f7" SCENES = [ { "key": "breakfast", "name": "breakfast", "description": "Breakfast on the kitchen table: milk, juice, a banana, an apple and bread", "words": ["Back", "eat", "drink", "more", "all done"], "background": f""" """, "shapes": [ ("table (dining)", [80, 520, 1040, 362], ""), ], "objects": [ (None, "1FA9F", 840, 40, 300), ("milk", "1F95B", 130, 300, 240), ("juice", "1F9C3", 350, 300, 240), ("banana", "1F34C", 560, 330, 220), ("apple", "1F34E", 760, 330, 210), ("bread", "1F35E", 940, 320, 230), ], }, { "key": "park", "name": "park", "description": "A park: a tree, a bird, the sun, a dog, a ball and a flower on the grass", "words": ["Back", "look", "play", "more", "stop"], "background": f""" """, "shapes": [ ("grass", [0, 560, 1200, 340], ""), ], "objects": [ (None, "2601", 500, 40, 220), ("sun", "2600", 960, 30, 200), ("tree", "1F333", 30, 150, 470), ("bird", "1F426", 600, 230, 150), ("dog", "1F415", 500, 560, 270), ("ball", "26BD", 860, 720, 140), ("flower", "1F337", 960, 440, 220), ], }, { "key": "bath", "name": "bath", "description": "Bath time: water from the shower into the bath, soap and a toothbrush on the shelf, and a towel", "words": ["Back", "wash", "hot (water)", "cold (water)", "all done"], "background": f""" {"".join(f'' for x in range(100, W, 100))} {"".join(f'' for y in range(100, 720, 100))} """, "shapes": [ ("towel", [770, 598, 280, 250], """ """), ], "objects": [ (None, "1FAE7", 150, 250, 170), (None, "1F6C1", 40, 300, 560), ("water", "1F4A7", 330, 420, 110), ("soap", "1F9FC", 690, 360, 150), ("toothbrush", "1FAA5", 900, 290, 220), ], }, { "key": "bedroom", "name": "bedroom", "description": "Bedtime: the bed, a book on the table, the lamp, the door and shoes on the floor", "words": ["Back", "read", "sleep", "help", "all done"], "background": f""" """, "shapes": [], "objects": [ (None, "1F319", 700, 85, 100), (None, "2B50", 790, 160, 80), ("lamp", "1F4A1", 480, 30, 170), ("bed", "1F6CF", 20, 330, 620), (None, "1F9F8", 380, 470, 140), ("book", "1F4D5", 700, 450, 150), ("door", "1F6AA", 910, 300, 330), ("shoes", "1F45F", 320, 740, 150), ], }, ] # ── OpenMoji ─────────────────────────────────────────────────────────────── def openmoji() -> tuple[zipfile.ZipFile, dict[str, dict]]: zip_path, meta_path = (symbols.fetch(s) for s in symbols.SOURCES["openmoji"]) meta = {e["hexcode"]: e for e in json.loads(meta_path.read_text(encoding="utf-8"))} return zipfile.ZipFile(zip_path), meta def emoji_svg(z: zipfile.ZipFile, hexcode: str, slug: str) -> str: """An OpenMoji drawing's inner markup, its ids made unique to this use.""" text = z.read(f"{hexcode}.svg").decode() inner = re.sub(r"^.*?]*>", "", text, flags=re.S) inner = re.sub(r"\s*$", "", inner, flags=re.S) inner = re.sub(r'id="([^"]+)"', lambda m: f'id="{slug}-{m.group(1)}"', inner) inner = re.sub(r"url\(#([^)]+)\)", lambda m: f"url(#{slug}-{m.group(1)})", inner) return inner.strip() def render(svg: str, width: int, height: int) -> Image.Image: rsvg = shutil.which("rsvg-convert") if not rsvg: raise SystemExit("draw.py needs rsvg-convert (librsvg): brew install librsvg, or apt install librsvg2-bin") out = subprocess.run([rsvg, "-w", str(width), "-h", str(height), "-f", "png"], input=svg.encode(), capture_output=True, check=True) return Image.open(io.BytesIO(out.stdout)).convert("RGBA") def drawn_box(z: zipfile.ZipFile, hexcode: str) -> tuple[float, float, float, float]: """Where an OpenMoji drawing's strokes are in its 72 x 72 box.""" scale = 8 svg = f'{emoji_svg(z, hexcode, "m")}' box = render(svg, 72 * scale, 72 * scale).getchannel("A").getbbox() if not box: raise SystemExit(f"OpenMoji {hexcode} draws nothing") return tuple(v / scale for v in box) # type: ignore[return-value] # ── drawing ──────────────────────────────────────────────────────────────── def at_least(x: int, y: int, w: int, h: int) -> list[int]: """The box grown about its middle to MIN_SIDE each way, inside the picture.""" def side(start: int, size: int, whole: int) -> tuple[int, int]: if size >= MIN_SIDE: return start, size start = start - (MIN_SIDE - size) // 2 return min(max(0, start), whole - MIN_SIDE), MIN_SIDE (x, w), (y, h) = side(x, w, W), side(y, h, H) return [x, y, w, h] def compose(z: zipfile.ZipFile, scene: dict) -> tuple[str, list[dict]]: parts = [f'', scene["background"]] hotspots = [] for word, rect, svg in scene["shapes"]: parts.append(svg) hotspots.append({"word": word, "rect": at_least(*rect)}) for n, (word, hexcode, x, y, size) in enumerate(scene["objects"]): slug = f"{scene['key']}-{n}" parts.append(f'{emoji_svg(z, hexcode, slug)}') if word: bx0, by0, bx1, by1 = drawn_box(z, hexcode) s = size / 72 x0, y0 = max(0, round(x + bx0 * s) - PAD), max(0, round(y + by0 * s) - PAD) x1, y1 = min(W, round(x + bx1 * s) + PAD), min(H, round(y + by1 * s) + PAD) hotspots.append({"word": word, "rect": at_least(x0, y0, x1 - x0, y1 - y0), "openmoji": hexcode}) parts.append("") small = [h["word"] for h in hotspots if min(h["rect"][2:]) < MIN_SIDE] if small: raise SystemExit(f"{scene['key']}: hotspots smaller than {MIN_SIDE} px: {small}") return "\n".join(parts), hotspots def main() -> None: z, meta = openmoji() out = { "about": "The starter scenes, drawn by draw.py: each picture's hotspots in picture pixels. Generated; do not edit by hand.", "width": W, "height": H, "openmoji": { "version": symbols.OPENMOJI_TAG, "licence": "CC-BY-SA-4.0", "source": f"https://github.com/hfg-gmuend/openmoji/releases/tag/{symbols.OPENMOJI_TAG}", }, "scenes": [], } for scene in SCENES: svg, hotspots = compose(z, scene) picture = render(svg, W, H).convert("RGB") # 256 colours: a flat drawing keeps its look at a fraction of the size. small = picture.quantize(colors=256, method=Image.Quantize.MEDIANCUT, dither=Image.Dither.NONE) path = HERE / "pictures" / f"{scene['key']}.png" path.parent.mkdir(exist_ok=True) small.save(path, optimize=True) used = sorted({h for _, h, *_ in scene["objects"]}) out["scenes"].append({ "key": scene["key"], "name": scene["name"], "description": scene["description"], "picture": f"pictures/{scene['key']}.png", "words": scene["words"], "hotspots": hotspots, "openmoji": [{"hexcode": h, "name": meta[h]["annotation"], "author": meta[h].get("openmoji_author", "")} for h in used], }) print(f"{path.relative_to(AAC_RS)}: {path.stat().st_size // 1024} KB, {len(hotspots)} hotspots") (HERE / "scenes.json").write_text(json.dumps(out, ensure_ascii=False, indent=1) + "\n", encoding="utf-8") (HERE / "pictures" / "LICENSE.md").write_text(license_md(out), encoding="utf-8") def license_md(out: dict) -> str: """The pictures' licence and every OpenMoji drawing each is made with.""" lines = [ "# Starter scenes \u2014 pictures and their licence", "", "Generated by `tools/pagesets/scenes/draw.py`; do not edit by hand.", "", "Each picture here was drawn for the AAC open standard's starter scenes, with objects from", f"[OpenMoji {out['openmoji']['version']}]({out['openmoji']['source']}) \u2014 the open-source emoji and", "icon project (https://openmoji.org) \u2014 placed on a background of plain shapes. The pictures are", "licensed CC BY-SA 4.0 (https://creativecommons.org/licenses/by-sa/4.0/), as OpenMoji is; the", "OpenMoji drawings are adapted (placed, scaled, and drawn into one picture). No person is drawn.", "", ] for scene in out["scenes"]: lines.append(f"## {scene['picture']}") lines.append("") lines.append(scene["description"] + ".") lines.append("") for o in scene["openmoji"]: lines.append(f"- OpenMoji {o['hexcode']} \u201c{o['name']}\u201d, by {o['author'] or 'the OpenMoji team'}") lines.append("") return "\n".join(lines) if __name__ == "__main__": with tempfile.TemporaryDirectory(): main()