open-standards/aac/aac-rs/tools/pagesets/scenes/draw.py

317 lines
15 KiB
Python

#!/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/<key>.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"""
<rect width="{W}" height="{H}" fill="#f6e7c8"/>
<rect y="430" width="{W}" height="10" fill="#e8d3a8"/>
<rect y="700" width="{W}" height="200" fill="#d9c2a0"/>
<rect x="80" y="520" width="1040" height="58" rx="14" fill="#a0673a"/>
<rect x="96" y="578" width="1008" height="34" fill="#8a5530"/>
<rect x="130" y="612" width="44" height="270" rx="8" fill="#8a5530"/>
<rect x="1026" y="612" width="44" height="270" rx="8" fill="#8a5530"/>
""",
"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"""
<rect width="{W}" height="{H}" fill="{SKY}"/>
<path d="M0 560 Q 300 500 620 545 T 1200 530 L 1200 900 L 0 900 Z" fill="#8bc47a"/>
<path d="M0 700 Q 400 660 800 700 T 1200 690 L 1200 900 L 0 900 Z" fill="#7ab86a"/>
""",
"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"""
<rect width="{W}" height="{H}" fill="#ddeff2"/>
<g stroke="#c5e0e6" stroke-width="3">
{"".join(f'<line x1="{x}" y1="0" x2="{x}" y2="720"/>' for x in range(100, W, 100))}
{"".join(f'<line x1="0" y1="{y}" x2="{W}" y2="{y}"/>' for y in range(100, 720, 100))}
</g>
<rect y="720" width="{W}" height="180" fill="#b9c7cc"/>
<rect x="660" y="500" width="480" height="24" rx="6" fill="#90a4ae"/>
<rect x="740" y="590" width="340" height="16" rx="8" fill="#78909c"/>
""",
"shapes": [
("towel", [770, 598, 280, 250], """
<rect x="770" y="598" width="280" height="250" rx="12" fill="#f28b82"/>
<rect x="770" y="780" width="280" height="22" fill="#fbe0dd"/>
<rect x="770" y="812" width="280" height="10" fill="#fbe0dd"/>
<rect x="770" y="598" width="280" height="36" rx="12" fill="#e57373"/>
"""),
],
"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"""
<rect width="{W}" height="{H}" fill="#cbd3f0"/>
<rect y="740" width="{W}" height="160" fill="#a98c6b"/>
<rect x="560" y="0" width="8" height="40" fill="#5c6bc0"/>
<rect x="680" y="60" width="200" height="220" rx="8" fill="#5c6bc0"/>
<rect x="692" y="72" width="176" height="196" rx="4" fill="#1e2a5a"/>
<rect x="660" y="590" width="220" height="16" rx="6" fill="#8d6e63"/>
<rect x="676" y="606" width="188" height="170" rx="8" fill="#a1887f"/>
""",
"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"^.*?<svg[^>]*>", "", text, flags=re.S)
inner = re.sub(r"</svg>\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'<svg xmlns="http://www.w3.org/2000/svg" width="{72 * scale}" height="{72 * scale}" viewBox="0 0 72 72">{emoji_svg(z, hexcode, "m")}</svg>'
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'<svg xmlns="http://www.w3.org/2000/svg" width="{W}" height="{H}" viewBox="0 0 {W} {H}">', 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'<svg x="{x}" y="{y}" width="{size}" height="{size}" viewBox="0 0 72 72">{emoji_svg(z, hexcode, slug)}</svg>')
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("</svg>")
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()