306 lines
14 KiB
Python
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()
|