"""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 sense_of(data: Data, key: str) -> tuple[str, str | None]: """A key that names the sense it is glossed in ("cold (water)") as its word and that sense ("cold", "water"); any other key as itself, with none (aac-core translations::english_word, GLOSS_SENSE).""" if key.endswith(")") and " (" in key and key in data.translations: word, sense = key[:-1].rsplit(" (", 1) if word.strip(): return word, sense return key, None def word_translations(data: Data, key: str) -> dict: glosses = data.glosses(key) if not glosses: return {} out = {"en": {"label": sense_of(data, key)[0]}} 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 # A key glossed in a sense ("cold (water)") is written as its word # ("cold"), the sense as data: a reader that does not know it shows # and says the word (proposed SPEC §2.1 gloss_sense). word, gloss_sense = sense_of(data, b.key) btn: dict = {"id": bid, "label": word} prov = {"content": "text", "source_label": word, "label": "source"} if gloss_sense: btn["ext_aurasense_gloss_sense"] = gloss_sense if b.action == "speak": said = text_for_speech(data, word) if said != word: btn["ext_aurasense_spoken"] = said btn["vocalization"] = word 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, word, 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) # One word, one place (aac-core samewords::mark): where a language says # two of the board's keys with one word, the key that keeps it shows it, # and the other is hidden in its place in that language. # (Words only: a button that opens a page or does something is never # hidden, and keeps no word.) def word(b: Button) -> bool: return b.action in ("speak", "modifier") and not b.control and not b.sense # Of a group's keys on the board, the one shown first keeps the word (on # a set that grows: `shown_from`), and of those shown together the first # of the group (the one whose meaning the word has). glossed: dict[str, int] = {} for b in board.buttons: if word(b): at = int((b.pageset or {}).get("shown_from", 0)) glossed[b.key] = min(at, glossed.get(b.key, at)) for b, btn in zip(board.buttons, buttons): if not word(b): continue hidden = {} for lang, groups in data.same_words.items(): group = next((g for g in groups if b.key in g), None) if group is None: continue on_board = [(glossed[k], i, k) for i, k in enumerate(group) if k in glossed] keeper = min(on_board)[2] if on_board else None if keeper is not None and keeper != b.key: hidden[lang] = keeper if hidden: btn["ext_aurasense_same_word"] = dict(sorted(hidden.items())) 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("