open-standards/aac/aac-rs/tools/pagesets/scenes/build.py
2026-10-13 02:20:55 -04:00

306 lines
14 KiB
Python

#!/usr/bin/env python3
"""Build the starter scenes (visual scene displays, proposed SPEC §2.5) as an
Open Board Format package for the page-set catalogue.
python3 tools/pagesets/scenes/build.py --out DIR
writes DIR/scenes/scenes-starter.obz and DIR/families.json (the generator
contract of tools/pagesets/catalog.py). Deterministic: the same sources give
the same bytes. The sources are committed beside this file: scenes.json and
pictures/*.png, drawn by draw.py (the pictures are drawn once, not at every
build, so a build never depends on how a renderer draws).
The package: a "pictures" board linking to four scenes, each a board whose
`ext_aurasense_scene` carries its picture (a `data:` URI, with its licence:
the picture travels inside the scene, as an app exports one) and whose first
row is the scene's row of words (Back first, then the scene's own words and a
few core words); each hotspot is a button of the grid below with its
`ext_aurasense_hotspot`, its place on the picture in ten-thousandths of the
picture's width and height. An app without scenes shows the hotspots as
buttons.
Every word is one of the app's English keys, and has a gloss in each of the
app's other 44 languages, agreed in the sense it has on its picture by the
two readers of a gloss audit (aac-core gloss_audit_*.json: `senses` and
`checked`; checked here, and the build refuses a word whose sense was not
audited or whose gloss changed since): the set is shown and said in the
person's language through the app's glosses (catalogue `lang` "*"), and no
cell is marked as a glossed set's word, so none is ever hidden for want of a
gloss.
Requires Python 3.11+ with PyYAML (for tools/pagesets/pslib).
"""
from __future__ import annotations
import argparse
import base64
import json
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parent))
from pslib import samewords # noqa: E402
from pslib.data import DEFAULT_ROLE, Data # noqa: E402
from pslib.obf import OBF_FORMAT, SOURCE_URL, Board, Button, board_json, stored_zip, uid # noqa: E402
FAMILY = "scenes"
PACKAGE = "scenes-starter"
VERSION = "1"
UNIT = 10_000
COLS = 5
MENU = "pictures"
SCENES_URL = SOURCE_URL + "/scenes"
ATTRIBUTION = (
"Starter scenes of the AAC open standard (Transaction Science), CC BY-SA 4.0: drawn for this project, with "
"objects from OpenMoji 17.0.0 (the open-source emoji and icon project, https://openmoji.org, CC BY-SA 4.0), "
"adapted. Symbols are named from Mulberry Symbols, OpenMoji and Blissymbolics, each CC BY-SA 4.0, with their "
"own attributions on each image."
)
DESCRIPTION = (
"Four pictures of everyday places to talk about: breakfast, the park, bath time and bedtime. Each has "
"hotspots on it to choose, and a row of words under it to say more than the picture shows. They are "
"drawings with no people in them. To make your own from a photo, use Edit mode: Boards, New scene from a photo."
)
LANGUAGE_NOTE = (
"The words are English, shown and said in your language through the app's translations, which are not your "
"language's own core words."
)
def picture_license(scene: dict) -> dict:
authors = ", ".join(sorted({o["author"] for o in scene["openmoji"] if o["author"]}))
return {
"type": "CC-BY-SA-4.0",
"copyright_notice_url": "https://creativecommons.org/licenses/by-sa/4.0/",
"source_url": SCENES_URL,
"author_name": "Transaction Science, AAC open standard page sets; OpenMoji",
# The credit, in a line (a printed scene's foot, aac-core
# `ObfLicense::credit`); each drawing's name is in pictures/LICENSE.md.
"ext_aurasense_attribution": (
f"Drawn for the AAC open standard's starter scenes with OpenMoji 17.0.0 drawings by {authors} "
"(https://openmoji.org), adapted."
),
}
def audited(data: Data, words: set[str], langs: list[str]) -> None:
"""Every word was audited in the sense it has on its picture, and its gloss
in each language is still the one both readers agreed (the gloss audits'
`senses` and `checked`). A word the audits do not know, or whose gloss was
changed since, needs a new audit first."""
senses: dict[str, str] = {}
agreed: dict[str, dict[str, str]] = {}
# In date order: a later audit's agreed word replaces an earlier one (a
# word changed so that two keys of a board are told apart, say).
for audit in data.audits:
senses.update(audit.get("senses", {}))
for code, glossed in audit.get("checked", {}).items():
agreed.setdefault(code, {}).update(glossed)
for code, changes in audit.get("languages", {}).items():
for change in changes:
if change.get("word") in agreed.get(code, {}) and change.get("new"):
agreed[code][change["word"]] = change["new"]
problems = []
for w in sorted(words):
if w not in senses:
problems.append(f"{w!r} has no audited sense")
continue
for code in langs:
gloss, ok = data.glosses(w).get(code), agreed.get(code, {}).get(w)
if ok is None:
problems.append(f"{w!r} was not audited in {code}")
elif gloss != ok:
problems.append(f"{w!r} in {code} is {gloss!r}, not {ok!r} as audited")
if problems:
raise SystemExit("scenes: words not audited in their sense (a gloss audit, aac-core gloss_audit_*.json, "
"must agree each first): " + "; ".join(problems[:24]) + (" …" if len(problems) > 24 else ""))
def to_unit(v: int, whole: int) -> int:
return round(v * UNIT / whole)
def hotspot_rect(rect: list[int], width: int, height: int) -> list[int]:
x, y, w, h = rect
ux, uy = to_unit(x, width), to_unit(y, height)
uw = min(to_unit(x + w, width), UNIT) - ux
uh = min(to_unit(y + h, height), UNIT) - uy
return [ux, uy, uw, uh]
def english_word(key: str) -> str:
"""The word of a key that names its sense ("cold (water)" → "cold"), as
aac-core `translations::english_word`."""
if key.endswith(")") and " (" in key:
return key[: key.rindex(" (")]
return key
def word_button(data: Data, key: str, row: int, col: int, **kw) -> Button:
"""A button for an English key, coloured and classed as the app's
vocabulary has its word (aac-core `word_class_of`)."""
known = data.app_vocabulary.get(english_word(key).lower())
cls = known[0] if known else None
role = DEFAULT_ROLE.get(cls or "fringe_noun", "thing")
return Button(key=key, row=row, col=col, action=kw.pop("action", "speak"), role=role, word_class=cls, **kw)
def build(out: Path) -> dict:
data = Data()
src = json.loads((HERE / "scenes.json").read_text(encoding="utf-8"))
width, height = src["width"], src["height"]
langs = [l["code"] for l in data.languages if l["code"] != "en"]
# Every word has a gloss in every language (or is a navigation control).
# Five to a row: a row of words prints at 30 mm across A4 (SPEC §13).
words: set[str] = {MENU}
for scene in src["scenes"]:
words.update(w for w in scene["words"] if w != "Back")
words.update(h["word"] for h in scene["hotspots"])
words.add(scene["name"])
for w in sorted(words):
missing = [c for c in langs if c not in data.glosses(w)]
if missing:
raise SystemExit(f"scenes: {w!r} has no gloss in {', '.join(missing)}: choose a word the app glosses")
audited(data, words, langs)
menu_id = uid(FAMILY, PACKAGE, "menu")
ids = {s["key"]: uid(FAMILY, PACKAGE, s["key"]) for s in src["scenes"]}
pageset = {"family": FAMILY, "version": VERSION, "package": PACKAGE}
boards: list[Board] = []
menu_buttons = [
word_button(data, s["name"], i // 2, i % 2, action="navigate", target=ids[s["key"]], symbol=None)
for i, s in enumerate(src["scenes"])
]
for b in menu_buttons:
b.role = "thing"
boards.append(Board(menu_id, MENU, 2, 2, menu_buttons, pageset=pageset))
hotspots_of: dict[str, list[tuple[str, list[int]]]] = {}
for scene in src["scenes"]:
buttons = []
for col, w in enumerate(scene["words"]):
if w == "Back":
buttons.append(Button(key="Back", row=0, col=col, action="navigate", role="control", target=menu_id, control="back"))
else:
buttons.append(word_button(data, w, 0, col))
# Hotspots below the row of words, from the top of the picture down.
spots = sorted(scene["hotspots"], key=lambda h: (h["rect"][1], h["rect"][0]))
placed = []
for n, h in enumerate(spots):
row, col = 1 + n // COLS, n % COLS
buttons.append(word_button(data, h["word"], row, col))
placed.append((h["word"], hotspot_rect(h["rect"], width, height)))
hotspots_of[scene["key"]] = placed
rows = 1 + max(1, -(-len(spots) // COLS))
boards.append(Board(ids[scene["key"]], scene["name"], rows, COLS, buttons, pageset=pageset))
paths = {b.id: f"boards/{b.id}.obf" for b in boards}
names = {b.id: b.name for b in boards}
files: list[tuple[str, bytes]] = []
by_key = {s["key"]: s for s in src["scenes"]}
for n, b in enumerate(boards):
value = board_json(data, b, names, paths, n + 1)
key = next((k for k, i in ids.items() if i == b.id), None)
if key:
scene = by_key[key]
png = (HERE / scene["picture"]).read_bytes()
value["ext_aurasense_scene"] = {
"version": 1,
"image": "data:image/png;base64," + base64.b64encode(png).decode("ascii"),
"image_license": picture_license(scene),
"width": width,
"height": height,
"description": scene["description"],
}
value["ext_aurasense_provenance"]["board_type"] = "VisualScene"
# Each hotspot's place on the picture. A partner may remove one
# (it is not locked); the row of words stays where it is.
for word, rect in hotspots_of[key]:
bid = uid(b.id, word)
btn = next(x for x in value["buttons"] if x["id"] == bid)
btn["ext_aurasense_hotspot"] = {"version": 1, "rect": rect}
btn["ext_aurasense_locked"] = False
# No scene shows the same words on two of its cells, in any language.
same = samewords.board_clashes(value, ["en", *langs], "*")
if same:
raise SystemExit(
"scenes: the same words on two cells of one scene: "
+ "; ".join(f"{b.name} in {c['lang']}: “{c['words']}” on {' / '.join(x['key'] for x in c['cells'])}" for c in same[:20])
)
files.append((paths[b.id], json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()))
manifest = {
"format": OBF_FORMAT,
"root": paths[menu_id],
"paths": {"boards": {b.id: paths[b.id] for b in boards}, "images": {}, "sounds": {}},
"ext_aurasense_pageset": {**pageset, "sources": [
{"name": "Scenes drawn for this project (tools/pagesets/scenes/draw.py)", "licence": "CC-BY-SA-4.0"},
{"name": f"OpenMoji {src['openmoji']['version']}", "licence": "CC-BY-SA-4.0", "url": src["openmoji"]["source"]},
]},
}
files.insert(0, ("manifest.json", json.dumps(manifest, ensure_ascii=False, sort_keys=True, indent=2).encode()))
pkg = out / FAMILY / f"{PACKAGE}.obz"
pkg.parent.mkdir(parents=True, exist_ok=True)
pkg.write_bytes(stored_zip(files))
share = {c: round(sum(1 for w in words if c in data.glosses(w)) / len(words), 3) for c in langs}
# English is the keys themselves, chosen for the pictures (no core list's
# status to claim, as for the phrase sets); every other language, the
# app's glosses.
languages = {c: {"status": "GlossOnly", "glossed": share[c]} for c in sorted(langs)}
cells = sum(len(b.buttons) for b in boards)
return {
"families": [{
"key": FAMILY,
"name": "Scenes",
"summary": "Pictures of everyday places with hotspots to choose (visual scene displays)",
"description": DESCRIPTION,
"chooser": "list",
"version": VERSION,
"licence": "CC-BY-SA-4.0",
"attribution": ATTRIBUTION,
"sources": [
{"name": "Scenes drawn for this project", "licence": "CC-BY-SA-4.0"},
# Shown in the chooser: the drawings' source, its link, and
# that they are adapted (CC BY-SA 4.0's attribution).
{"name": "OpenMoji 17.0.0 (https://openmoji.org), adapted: placed and scaled in the pictures", "licence": "CC-BY-SA-4.0"},
],
"languages": languages,
"language_note": LANGUAGE_NOTE,
"packages": [{
"key": PACKAGE,
"file": f"{FAMILY}/{PACKAGE}.obz",
"lang": "*",
"gender": None,
"variant": "full",
"size": str(len(src["scenes"])),
"rows": 2,
"cols": 2,
"label": f"{len(src['scenes'])} scenes",
"description": "Breakfast, the park, bath time and bedtime.",
"home": menu_id,
"boards": len(boards),
"cells": cells,
}],
}],
}
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--out", type=Path, required=True)
args = ap.parse_args()
args.out.mkdir(parents=True, exist_ok=True)
families = build(args.out)
(args.out / "families.json").write_text(json.dumps(families, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
pkg = args.out / FAMILY / f"{PACKAGE}.obz"
print(f"wrote {pkg} ({pkg.stat().st_size // 1024} KB) and families.json")
if __name__ == "__main__":
main()