open-standards/aac/aac-rs/tools/pagesets/pslib/obf.py
Transaction Science 59830b8985 wip(aac): page-set decisions, catalogue and chooser (in progress)
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]>
2026-10-05 15:53:59 -04:00

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)