#!/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()