317 lines
15 KiB
Python
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()
|