Work in progress after the coordinator's decisions of 2026-10-05; does not compile yet (aac-web chooser). Done so far: - aac-core: WordCategory::Conjunction with Fitzgerald colours for every class; nav module (Back/Home words moved from aac-web, shared with OBF export: Back/Home cells never carry the gloss); Cell.sense (a key used in another sense than its glosses'); pagesets module (catalogue, stages, reveal). - tools/pagesets: Fixed Paths 30 beginner Home; "back" the word in both sets; conjunction class; catalogue (catalog.py, generators.json) with the committed data/pagesets/index.json; packages no longer committed. - aac-web: words with no word in the person's language hidden in place, Edit mode says so; Back drawn as its arrow; catalogue chooser started. Co-Authored-By: Claude Opus 5.5 <[email protected]>
277 lines
11 KiB
Python
277 lines
11 KiB
Python
"""Open Board Format writer: what aac-core's exporter (obf.rs) writes, in Python.
|
|
|
|
A board written here reads back in the app exactly as the app's own export
|
|
would: English words as the stored keys (`ext_aurasense_provenance.
|
|
source_label`), every language's glosses in `translations`, the symbol the app
|
|
draws named in `images`, colours as rgb(), and the AuraSense extensions.
|
|
crates/aac-core/tests/pagesets.rs re-exports every imported board with
|
|
aac-core and compares, so this file cannot drift from the app unnoticed.
|
|
|
|
Two places where these files go beyond the app's export, on purpose:
|
|
- a Back or Home control cell carries the control's word in each language
|
|
(aac-core nav_labels.json, where both readers agreed), never the gloss of
|
|
the English word ("back" is the body part): SPEC §5.6 item 5. The app's
|
|
own exporter still writes the body-part gloss there (DESIGN-core.md);
|
|
- `hidden: true` (OBF's own field) on a word hidden in place, which the app
|
|
keeps once Track B's Cell.hidden has merged, and `ext_aurasense_symbol`
|
|
(Track B's chosen symbol) on page keys that show an exemplar picture.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import struct
|
|
import uuid
|
|
import zlib
|
|
from dataclasses import dataclass, field
|
|
|
|
from .data import PALETTE, TEXT_COLOR, Data
|
|
|
|
OBF_FORMAT = "open-board-0.1"
|
|
GENERATOR = "aac-pagesets"
|
|
CREATED_AT = "2026-10-05T00:00:00+00:00"
|
|
NAMESPACE = uuid.uuid5(uuid.NAMESPACE_URL, "https://git.transaction.science/Transaction-Science/open-standards/aac/pagesets")
|
|
SOURCE_URL = "https://git.transaction.science/Transaction-Science/open-standards/src/branch/main/aac/aac-rs/tools/pagesets"
|
|
SET_ORDER = ["mulberry", "openmoji", "bliss"]
|
|
NOMINAL = {"mulberry": (850, 850), "openmoji": (72, 72), "bliss": (324, 324)}
|
|
|
|
|
|
def board_license(data: Data) -> dict:
|
|
return {
|
|
"type": "CC-BY-SA-4.0",
|
|
"copyright_notice_url": "https://creativecommons.org/licenses/by-sa/4.0/",
|
|
"source_url": SOURCE_URL,
|
|
"author_name": "Transaction Science, AAC open standard page sets",
|
|
"ext_aurasense_attribution": data.catalog["attribution"].strip(),
|
|
}
|
|
|
|
|
|
def uid(*parts: str) -> str:
|
|
return str(uuid.uuid5(NAMESPACE, "/".join(parts)))
|
|
|
|
|
|
# ── words the app would show and say ─────────────────────────────────────
|
|
|
|
|
|
def normalize_concept(label: str) -> str:
|
|
"""aac-core symbols::normalize_concept."""
|
|
collapsed = " ".join(label.lower().split())
|
|
return collapsed.rstrip("?!.,;").rstrip()
|
|
|
|
|
|
def lookup_concept(data: Data, concept: str, preferred: str = "mulberry") -> tuple[str, str] | None:
|
|
sets = data.symbols["concepts"].get(concept)
|
|
if not sets:
|
|
return None
|
|
for s in [preferred] + [x for x in SET_ORDER if x != preferred]:
|
|
if s in sets:
|
|
return s, sets[s]
|
|
return None
|
|
|
|
|
|
def lookup_for_cell(data: Data, label: str, action: str) -> tuple[str, str] | None:
|
|
"""aac-core symbols::lookup_for_cell (Speak/Modifier/Navigate only)."""
|
|
if action == "navigate":
|
|
return lookup_concept(data, "nav:" + normalize_concept(label)) or lookup_concept(data, normalize_concept(label))
|
|
return lookup_concept(data, normalize_concept(label))
|
|
|
|
|
|
def text_for_speech(data: Data, text: str, lang: str = "en") -> str:
|
|
"""aac-core speech::text_for_speech."""
|
|
word = text.strip()
|
|
if not word or len(word.split()) > 1:
|
|
return text
|
|
words = {k.lower(): v["say"] for k, v in data.speech_lexicon["languages"].get(lang, {}).get("words", {}).items()}
|
|
if word.lower() in words:
|
|
return words[word.lower()]
|
|
if len(word) == 1 and word.isupper():
|
|
return word.lower()
|
|
return text
|
|
|
|
|
|
def control_translations(data: Data, which: str, english: str) -> dict:
|
|
out = {"en": {"label": english}}
|
|
for code, e in sorted(data.nav_labels["languages"].items()):
|
|
if e["review"]["status"] == "agreed" and which not in e["review"].get("disputed", []):
|
|
out[code] = {"label": e[which]}
|
|
return dict(sorted(out.items()))
|
|
|
|
|
|
def word_translations(data: Data, key: str) -> dict:
|
|
glosses = data.glosses(key)
|
|
if not glosses:
|
|
return {}
|
|
out = {"en": {"label": key}}
|
|
for code, g in glosses.items():
|
|
out[code] = {"label": g}
|
|
return dict(sorted(out.items()))
|
|
|
|
|
|
def rgb(hex_color: str) -> str:
|
|
h = hex_color.lstrip("#")
|
|
return f"rgb({int(h[0:2], 16)}, {int(h[2:4], 16)}, {int(h[4:6], 16)})"
|
|
|
|
|
|
# ── boards ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
@dataclass
|
|
class Button:
|
|
key: str # the stored English words (also the label)
|
|
row: int
|
|
col: int
|
|
action: str # speak | navigate | modifier
|
|
role: str # palette key
|
|
word_class: str | None = None
|
|
target: str | None = None # navigate: board id
|
|
modifier: str | None = None
|
|
control: str | None = None # "back" | "home" for a navigation control
|
|
symbol: str | None = None # a chosen concept (page keys)
|
|
sense: str | None = None # the key's sense, when not its glosses' (Cell::sense)
|
|
hidden: bool = False
|
|
pageset: dict = field(default_factory=dict)
|
|
|
|
|
|
@dataclass
|
|
class Board:
|
|
id: str
|
|
name: str
|
|
rows: int
|
|
cols: int
|
|
buttons: list[Button]
|
|
pageset: dict = field(default_factory=dict)
|
|
|
|
|
|
def _image(data: Data, set_name: str, sym_id: str, scope: int) -> dict:
|
|
info = data.symbols["sets"][set_name]
|
|
w, h = NOMINAL[set_name]
|
|
return {
|
|
"id": f"{sym_id}.{scope}",
|
|
"content_type": "image/svg+xml",
|
|
"width": w,
|
|
"height": h,
|
|
"symbol": {"set": set_name, "filename": f"{sym_id}.svg"},
|
|
"license": {
|
|
"type": info["license"]["spdx"],
|
|
"copyright_notice_url": info["license"]["url"],
|
|
"source_url": info["source"]["url"],
|
|
"author_name": info["name"],
|
|
"ext_aurasense_attribution": info["attribution"],
|
|
"ext_aurasense_conditions": info["conditions"],
|
|
},
|
|
}
|
|
|
|
|
|
def board_json(data: Data, board: Board, names: dict[str, str], paths: dict[str, str], scope: int) -> dict:
|
|
order = [[None] * board.cols for _ in range(board.rows)]
|
|
buttons = []
|
|
images: dict[str, dict] = {}
|
|
for b in board.buttons:
|
|
bid = uid(board.id, b.key)
|
|
if order[b.row][b.col] is not None:
|
|
raise ValueError(f"{board.name}: two buttons at {b.row},{b.col}")
|
|
order[b.row][b.col] = bid
|
|
btn: dict = {"id": bid, "label": b.key}
|
|
prov = {"content": "text", "source_label": b.key, "label": "source"}
|
|
if b.action == "speak":
|
|
said = text_for_speech(data, b.key)
|
|
if said != b.key:
|
|
btn["ext_aurasense_spoken"] = said
|
|
btn["vocalization"] = b.key
|
|
prov["vocalization"] = "source"
|
|
elif b.action == "navigate":
|
|
btn["load_board"] = {"id": b.target, "name": names[b.target], "path": paths[b.target]}
|
|
elif b.action == "modifier":
|
|
btn["action"] = f":ext_aurasense_{b.modifier}"
|
|
sym = lookup_concept(data, b.symbol) if b.symbol else lookup_for_cell(data, b.key, b.action)
|
|
if sym:
|
|
img = _image(data, sym[0], sym[1], scope)
|
|
images.setdefault(img["id"], img)
|
|
btn["image_id"] = img["id"]
|
|
if b.symbol:
|
|
btn["ext_aurasense_symbol"] = b.symbol
|
|
bg, border = PALETTE[b.role]
|
|
btn["background_color"] = rgb(bg)
|
|
btn["border_color"] = rgb(border)
|
|
btn["ext_aurasense_text_color"] = TEXT_COLOR
|
|
if b.control:
|
|
tr = control_translations(data, b.control, b.key)
|
|
elif b.sense:
|
|
# Another sense than the glosses': English only (aac-core Cell::sense).
|
|
btn["ext_aurasense_sense"] = b.sense
|
|
tr = {"en": {"label": b.key}}
|
|
else:
|
|
tr = word_translations(data, b.key)
|
|
if tr:
|
|
btn["translations"] = tr
|
|
btn["ext_aurasense_locked"] = True
|
|
if b.word_class:
|
|
btn["ext_aurasense_word_class"] = b.word_class
|
|
btn["ext_aurasense_provenance"] = prov
|
|
if b.hidden:
|
|
btn["hidden"] = True
|
|
if b.pageset:
|
|
btn["ext_aurasense_pageset"] = b.pageset
|
|
buttons.append(btn)
|
|
return {
|
|
"format": OBF_FORMAT,
|
|
"id": board.id,
|
|
"locale": "en",
|
|
"name": board.name,
|
|
"license": board_license(data),
|
|
"grid": {"rows": board.rows, "columns": board.cols, "order": order},
|
|
"buttons": buttons,
|
|
"images": list(images.values()),
|
|
"sounds": [],
|
|
"ext_aurasense_locked": True,
|
|
"ext_aurasense_provenance": {
|
|
"generator": GENERATOR,
|
|
"source_locale": "en",
|
|
"source_name": board.name,
|
|
"board_type": "Grid",
|
|
"created_at": CREATED_AT,
|
|
"core_status": "ValidatedNativeCorpus",
|
|
},
|
|
"ext_aurasense_pageset": board.pageset,
|
|
}
|
|
|
|
|
|
def obz(data: Data, boards: list[Board], root: str, manifest_ext: dict) -> bytes:
|
|
ordered = sorted(boards, key=lambda b: (b.id != root, b.name, b.id))
|
|
paths = {b.id: f"boards/{b.id}.obf" for b in ordered}
|
|
names = {b.id: b.name for b in ordered}
|
|
files = []
|
|
for n, b in enumerate(ordered):
|
|
value = board_json(data, b, names, paths, n + 1)
|
|
files.append((paths[b.id], json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()))
|
|
manifest = {
|
|
"format": OBF_FORMAT,
|
|
"root": paths[root],
|
|
"paths": {"boards": {b.id: paths[b.id] for b in ordered}, "images": {}, "sounds": {}},
|
|
"ext_aurasense_pageset": manifest_ext,
|
|
}
|
|
files.insert(0, ("manifest.json", json.dumps(manifest, ensure_ascii=False, sort_keys=True, indent=2).encode()))
|
|
return stored_zip(files)
|
|
|
|
|
|
DOS_DATE = (1 << 5) | 1 # 1980-01-01, fixed so a build is reproducible
|
|
|
|
|
|
def stored_zip(files: list[tuple[str, bytes]]) -> bytes:
|
|
"""An uncompressed zip, as aac-core's write_stored_zip writes it."""
|
|
out = bytearray()
|
|
central = bytearray()
|
|
for name, data in files:
|
|
offset = len(out)
|
|
crc = zlib.crc32(data) & 0xFFFFFFFF
|
|
nb = name.encode()
|
|
out += struct.pack("<IHHHHHIIIHH", 0x04034B50, 20, 0x0800, 0, 0, DOS_DATE, crc, len(data), len(data), len(nb), 0)
|
|
out += nb + data
|
|
central += struct.pack(
|
|
"<IHHHHHHIIIHHHHHII", 0x02014B50, 20, 20, 0x0800, 0, 0, DOS_DATE, crc, len(data), len(data), len(nb), 0, 0, 0, 0, 0, offset
|
|
)
|
|
central += nb
|
|
cd_offset = len(out)
|
|
out += central
|
|
out += struct.pack("<IHHHHIIH", 0x06054B50, 0, 0, len(files), len(files), len(central), cd_offset, 0)
|
|
return bytes(out)
|