open-standards/aac/aac-rs/tools/pagesets/phrases/build.py
Transaction Science 8ce1532b64 fix(pagesets): the phrase sets' language note says "two independent AI models agreed"
"Two independent model readers" read as people who model (the Turkish judge
caught "two exemplary readers"). catalog.py --write: the four phrase
families' language_note in data/pagesets/index.json (no package changes); its
message is named by a hash of the English, so pageset-catalogue-8712848ae6d2
gives way to pageset-catalogue-8c8211ab1b93.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 21:31:47 -04:00

1460 lines
66 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Build the phrase-based page sets as Open Board Format packages (.obz).
Four page sets, each written by hand as a human-readable source in
sources/*.yaml and built here into OBF boards packed in .obz files:
adult.yaml adults with acquired conditions (aphasia, ALS/MND, PPA,
TBI, Parkinson's): one package, adult.obz
intent.yaml intent-first vocabulary in 12, 20 and 40 cells:
intent-12.obz, intent-20.obz, intent-40.obz
themes.yaml themed phrase grids, 6 x 3: themes.obz
emergent.yaml first switches and single messages: emergent.obz
Generated packages are not committed: the site build makes them. What
`--out DIR` writes (deterministic: the same sources give the same bytes):
DIR/<set>.obz English, with the pictures and every agreed
translation on each button
DIR/<lang>/<set>.obz the set in one language; <set>.f.obz and
<set>.m.obz where the speaker's gender changes
the words
DIR/index.json the catalogue a page-set chooser reads: sets,
sizes, descriptions, licence, every file with
its size and sha256, per-language status
DIR/families.json the same sets as families of the page-set
catalogue (tools/pagesets/catalog.py's
generator contract), so the catalogue can list
them beside the core sets
DIR/LICENSE.md CC BY-SA 4.0, with the symbols' attribution
Commands:
python3 build.py --out DIR [--hash-names] build into DIR (--hash-names:
<set>.<sha256-12>.obz, for a
cache that keeps files for hours)
python3 build.py --check [--out DIR] validate; fail if two builds
differ, if reports/ is stale, or
(with --out) if DIR is stale
python3 build.py --reports rewrite reports/*.md (committed
review aids: contents, symbols
wanted, translation status)
python3 build.py --strings work/strings.json for translators
Requires Python >= 3.11 and PyYAML. Reads the symbol index
(crates/aac-core/src/symbols/index.json) and sprites
(crates/aac-web/public/symbols/*.svg) the app ships; never the network.
The data model, the extensions and the translation process are described in
tools/pagesets/DESIGN-phrases-common.md.
"""
from __future__ import annotations
import argparse
import hashlib
import html
import io
import json
import re
import sys
import uuid
import zipfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import yaml
HERE = Path(__file__).resolve().parent
AAC_RS = HERE.parents[2]
SOURCES = HERE / "sources"
TRANSLATIONS = HERE / "translations"
REPORTS = HERE / "reports"
WORK = HERE / "work"
SYMBOL_INDEX = AAC_RS / "crates" / "aac-core" / "src" / "symbols" / "index.json"
SYMBOL_INDEX_NC = AAC_RS / "crates" / "aac-core" / "src" / "symbols" / "index-nc.json"
SPRITES = AAC_RS / "crates" / "aac-web" / "public" / "symbols"
OBF_FORMAT = "open-board-0.1"
GENERATOR = "aac-rs/tools/pagesets/phrases/build.py"
# Ids are UUIDv5 under this namespace, so a rebuilt board keeps its id.
NAMESPACE = uuid.UUID("6f0f3a52-8a4e-5b55-9a43-7b1d2c0e5a11")
# 1980-01-01, the earliest DOS date: fixed so a build is reproducible.
ZIP_DATE = (1980, 1, 1, 0, 0, 0)
# The ten largest app languages, in the order the work was done. The other
# 34 are listed as pending in the catalogue.
LANGUAGES = {
"es": ("Spanish", "es-ES"),
"fr": ("French", "fr-FR"),
"de": ("German", "de-DE"),
"pt": ("Portuguese (Brazil)", "pt-BR"),
"it": ("Italian", "it-IT"),
"zh": ("Chinese (Simplified)", "zh-CN"),
"ja": ("Japanese", "ja-JP"),
"ar": ("Arabic", "ar-SA"),
"hi": ("Hindi", "hi-IN"),
"ru": ("Russian", "ru-RU"),
}
PENDING_LANGUAGES = [
"nl", "ko", "he", "ur", "fa", "bn", "ta", "te", "ml", "mr", "kn", "gu", "pa", "th", "vi", "id",
"ms", "tl", "pl", "uk", "ro", "cs", "sv", "da", "nb", "fi", "sw", "am", "ha", "yo", "zu", "tr",
"el", "hu",
]
AGREED = "agreed"
STATUS_TEXT = "agreed by two model readers; native review pending"
# Names that belong to other people's products. None may appear in a board's
# words (the design notes may name them, to say what we did not copy).
PROPRIETARY_NAMES = [
"PODD", "Pragmatic Organisation", "Pragmatic Organization", "TD Snap", "Snap Core", "Core First",
"Tobii", "Dynavox", "Proloquo", "Crescendo", "LAMP", "Words for Life", "Unity", "Minspeak",
"Gateway", "WordPower", "TouchChat", "Lingraphica", "Aphasia Duo", "Grid 3", "Super Core",
"BIGmack", "Big Mack", "Step-by-Step", "Step by Step", "QuickFire", "Communication Journey",
"PECS", "Boardmaker", "PCS", "SymbolStix", "Widgit",
]
# Words that would read as diagnosis or treatment. The sets are for
# wellness, research and clinical-trial use, not a medical device.
CLAIM_WORDS = [
r"\bdiagnos\w*", r"\btreatments?\b", r"\btherap\w*", r"\bcure[sd]?\b", r"\bheal(s|ing|ed)?\b",
r"\brehabilitat\w*", r"\brecover(y|s|ing|ed)?\b", r"\bclinically\b", r"\bimproves? your\b",
r"\bslows? (the|your|down the)\b", r"\bprevents?\b",
]
ACTIONS = {"clear": ":clear", "backspace": ":backspace", "speak": ":speak"}
class SourceError(Exception):
pass
class PhraseLoader(yaml.SafeLoader):
"""YAML without YAML 1.1's other booleans: in a phrase source, yes, no,
on and off are words (a cell named `no`, the message "Yes"), and only
true and false are booleans."""
PhraseLoader.yaml_implicit_resolvers = {
first: [(tag, regexp) for tag, regexp in resolvers if tag != "tag:yaml.org,2002:bool"]
for first, resolvers in yaml.SafeLoader.yaml_implicit_resolvers.items()
}
PhraseLoader.add_implicit_resolver(
"tag:yaml.org,2002:bool", re.compile(r"^(?:true|false)$"), list("tf"))
# ─── Symbols ────────────────────────────────────────────────────────────────
class Symbols:
"""The app's concept -> symbol index and its sprites."""
def __init__(self) -> None:
index = json.loads(SYMBOL_INDEX.read_text())
self.sets: dict[str, dict] = index["sets"]
self.concepts: dict[str, dict[str, str]] = index["concepts"]
self._sprites: dict[str, str] = {}
def resolve(self, concept: str, prefer: list[str]) -> tuple[str, str] | None:
"""(set, symbol id) for a concept, from the first preferred set that
draws it."""
sets = self.concepts.get(concept)
if not sets:
return None
for s in prefer:
if s in sets:
return s, sets[s]
return None
def svg(self, set_name: str, symbol_id: str) -> tuple[bytes, int, int]:
"""The symbol cut out of its sprite as a standalone SVG document
(as aac_core::obf::extract_symbol_svg does), with its attribution."""
if set_name not in self._sprites:
self._sprites[set_name] = (SPRITES / self.sets[set_name]["file"]).read_text()
sprite = self._sprites[set_name]
start = sprite.find(f'<symbol id="{symbol_id}"')
if start < 0:
raise SourceError(f"symbol {symbol_id} is not in the {set_name} sprite")
tag_end = sprite.index(">", start)
tag = sprite[start:tag_end]
body_end = sprite.index("</symbol>", tag_end)
body = sprite[tag_end + 1:body_end]
view_box = re.search(r'viewBox="([^"]+)"', tag).group(1)
dims = [float(n) for n in re.split(r"[ ,]+", view_box.strip())]
width, height = max(1, int(-(-dims[2] // 1))), max(1, int(-(-dims[3] // 1)))
note = f'{self.sets[set_name]["attribution"]} Adapted: optimised and cut from a sprite.'.replace("--", "-")
doc = (f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="{view_box}" width="{width}" '
f'height="{height}"><!-- {note} -->{body}</svg>')
return doc.encode(), width, height
def license(self, set_name: str) -> dict:
info = self.sets[set_name]
return {
"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"],
}
# ─── Model ──────────────────────────────────────────────────────────────────
@dataclass
class Cell:
row: int
col: int
label: str = ""
say: str | None = None # Speak
add: str | None = None # Buffer: add to the message without speaking
go: str | None = None # board key
do: str | None = None # clear | backspace | speak
symbol: str | None = None # concept in the symbol index
want_symbol: str | None = None # a concept the index does not have yet
color: str | None = None
border: str | None = None
text_color: str | None = None
fill: dict | None = None # ext_aurasense_fill_in
seq: dict | None = None # ext_aurasense_sequence
effect: str | None = None # ext_aurasense_effect
src: str | None = None # source of the words, if not original
note: str | None = None # context for translators
origin: str = "" # where in the sources this cell came from
@property
def blank(self) -> bool:
return not (self.label or self.say or self.add or self.go or self.do)
@dataclass
class Board:
key: str
name: str
rows: int
cols: int
cells: list[Cell] = field(default_factory=list)
parent: str | None = None
popup: bool = False
speak_only: bool = False
description: str = ""
page: int | None = None
page_no: int | None = None # 2, 3 … on a section's continuation pages
origin: str = ""
def display(self, name: str) -> str:
"""The board's name (in any language) with its page number."""
return name if self.page_no is None else f"{name} {self.page_no}"
@dataclass
class PageSet:
key: str # file name stem, e.g. adult, intent-20
family: str # source file stem, e.g. adult, intent
name: str
description: str
version: str
license: dict
prefer: list[str]
root: str
boards: dict[str, Board]
size: int | None = None
# ─── Reading the sources ────────────────────────────────────────────────────
def hex_to_obf(color: str) -> str:
c = color.strip().lstrip("#")
if len(c) == 3:
c = "".join(ch * 2 for ch in c)
if not re.fullmatch(r"[0-9A-Fa-f]{6}", c):
raise SourceError(f"not a #RRGGBB colour: {color!r}")
r, g, b = (int(c[i:i + 2], 16) for i in (0, 2, 4))
return f"rgb({r}, {g}, {b})"
class Reader:
"""Turns one source file into page sets."""
def __init__(self, path: Path) -> None:
self.path = path
self.doc = yaml.load(path.read_text(), Loader=PhraseLoader)
self.meta = self.doc["set"]
self.palette: dict[str, str] = self.doc.get("palette", {})
self.named: dict[str, Any] = self.doc.get("cells", {})
def color(self, value: str | None) -> str | None:
if value is None:
return None
return self.palette.get(value, value)
def cell(self, spec: Any, row: int, col: int, origin: str, inherited: dict | None = None) -> Cell:
"""A cell from its source form: None (blank), a string (a message:
shown and said), "$name" (a named cell), or a mapping."""
if spec is None:
return Cell(row, col, origin=origin)
if isinstance(spec, str) and spec.startswith("$"):
name = spec[1:]
if name not in self.named:
raise SourceError(f"{origin}: no named cell ${name}")
return self.cell(self.named[name], row, col, origin, inherited)
if isinstance(spec, str):
spec = {"say": spec}
if not isinstance(spec, dict):
raise SourceError(f"{origin}: a cell is a string, $name, ~ or a mapping, not {spec!r}")
if "use" in spec:
base = self.named.get(spec["use"])
if base is None:
raise SourceError(f"{origin}: no named cell {spec['use']}")
merged = dict(base if isinstance(base, dict) else {"say": base})
merged.update({k: v for k, v in spec.items() if k != "use"})
spec = merged
known = {"label", "say", "add", "go", "do", "symbol", "want_symbol", "color", "border", "text_color",
"fill", "seq", "effect", "src", "note", "min_size"}
unknown = set(spec) - known
if unknown:
raise SourceError(f"{origin}: unknown cell keys {sorted(unknown)}")
style = dict(inherited or {})
c = Cell(
row, col,
label=str(spec.get("label") or spec.get("say") or spec.get("add") or ""),
say=spec.get("say"),
add=spec.get("add"),
go=spec.get("go"),
do=spec.get("do"),
symbol=spec.get("symbol"),
want_symbol=spec.get("want_symbol"),
color=self.color(spec.get("color", style.get("color"))),
border=self.color(spec.get("border", style.get("border"))),
text_color=self.color(spec.get("text_color", style.get("text_color"))),
fill=spec.get("fill"),
seq=spec.get("seq"),
effect=spec.get("effect"),
src=spec.get("src"),
note=spec.get("note"),
origin=origin,
)
if c.seq is not None and not c.label:
raise SourceError(f"{origin}: a sequence cell needs a label")
if sum(x is not None for x in (c.say, c.add, c.do)) > 1:
raise SourceError(f"{origin}: a cell says, adds or acts — one of them")
if c.do is not None and c.do not in ACTIONS:
raise SourceError(f"{origin}: unknown action {c.do!r}")
if c.go is None and c.do is None and c.add is None and c.say is None and c.label and c.seq is None:
c.say = c.label
if c.seq is not None and c.say is None:
# Fallback for a client without the extension: say the first line.
first = c.seq["lines"][0]
c.say = first["say"] if isinstance(first, dict) else (None if first.startswith("@") else first)
return c
def board(self, key: str, spec: dict, rows: int, cols: int, frame: list[dict] | None = None) -> Board:
origin = f"{self.path.name}#{key}"
b = Board(
key=key,
name=spec["name"],
rows=rows,
cols=cols,
parent=spec.get("parent"),
popup=bool(spec.get("popup", False)),
speak_only=bool(spec.get("speak_only", False)),
description=spec.get("description", ""),
origin=origin,
)
style = spec.get("style")
grid: list[list[Any]] = [[None] * cols for _ in range(rows)]
for f in frame or []:
r, c = f["at"]
grid[r][c] = f["cell"]
if "rows" in spec:
if len(spec["rows"]) > rows:
raise SourceError(f"{origin}: {len(spec['rows'])} rows in a {rows}-row grid")
first = 0
if frame and spec.get("frame_row", True) and any(f["at"][0] == 0 for f in frame):
first = 1 if len(spec["rows"]) < rows else 0
for r, row in enumerate(spec["rows"], start=first):
if len(row) > cols:
raise SourceError(f"{origin}: row {r} has {len(row)} cells in a {cols}-column grid")
for c, item in enumerate(row):
if item is not None:
if grid[r][c] is not None:
raise SourceError(f"{origin}: ({r},{c}) is the frame's")
grid[r][c] = item
for r in range(rows):
for c in range(cols):
cell = self.cell(grid[r][c], r, c, f"{origin}({r},{c})", style)
if not cell.blank:
b.cells.append(cell)
return b
def page_set(self, key: str, boards: dict[str, Board], size: int | None = None) -> PageSet:
m = self.meta
# A page set is shared under CC BY-SA and may be offered anywhere: it
# draws only the open sets, never one licensed for non-commercial use
# only (index-nc.json: ARASAAC, Sclera).
nc = json.loads(SYMBOL_INDEX_NC.read_text())["sets"] if SYMBOL_INDEX_NC.exists() else {}
open_sets = json.loads(SYMBOL_INDEX.read_text())["sets"]
if bad := [s for s in m["symbols"] if s in nc or s not in open_sets]:
raise SourceError(f"{self.path.name}: symbols {bad}: a page set draws only the open sets {sorted(open_sets)}")
return PageSet(
key=key,
family=self.path.stem,
name=m["name"] if size is None else m["name"].format(size=size),
description=m["description"].strip() if size is None else m["description"].strip().format(size=size),
version=str(m["version"]),
license=m["license"],
prefer=m["symbols"],
root=m["root"],
boards=boards,
size=size,
)
def read_plain(path: Path) -> list[PageSet]:
"""A set whose boards are each laid out by hand (adult, themes, emergent)."""
rd = Reader(path)
frames = rd.doc.get("frames", {})
boards: dict[str, Board] = {}
for key, spec in rd.doc["boards"].items():
rows, cols = spec.get("grid", rd.meta.get("grid"))
frame_name = spec.get("frame", rd.meta.get("frame"))
frame = frames.get(frame_name) if frame_name else None
if key in boards:
raise SourceError(f"{path.name}: board {key} twice")
boards[key] = rd.board(key, spec, rows, cols, frame)
finish(boards, rd.meta["root"], path.name)
return [rd.page_set(rd.path.stem, boards)]
def read_intent(path: Path) -> list[PageSet]:
"""The intent-first set: sections of items flowed into pages, for each
grid size, inside that size's frame of operational commands."""
rd = Reader(path)
out = []
for size, layout in rd.doc["sizes"].items():
size = int(size)
rows, cols = layout["grid"]
frame: list[dict] = layout["frame"]
turn = tuple(layout["turn"])
taken = {tuple(f["at"]) for f in frame}
content = [(r, c) for r in range(rows) for c in range(cols) if (r, c) not in taken]
boards: dict[str, Board] = {}
sections = rd.doc["sections"]
for key, sec in sections.items():
items = sec.get("items_by_size", {}).get(size, sec.get("items_by_size", {}).get(str(size)))
if items is None:
items = [it for it in sec["items"] if not (isinstance(it, dict) and it.get("min_size", 0) > size)]
pages = paginate(items, content, turn)
for n, page_items in enumerate(pages):
bkey = key if n == 0 else f"{key}~{n + 1}"
b = Board(key=bkey, name=sec["name"], rows=rows, cols=cols, page_no=n + 1 if n else None, parent=sec.get("parent", rd.meta["root"]),
popup=bool(sec.get("popup", False)), description=sec.get("description", ""),
origin=f"{path.name}#{key}@{size}")
if key == rd.meta["root"]:
b.parent = None
for f in frame:
cell = rd.cell(f["cell"], *f["at"], origin=f"{path.name}#frame@{size}")
if cell.go == "@back" and b.parent is None:
continue
if cell.go == "@back":
cell.go = b.parent
if cell.go == bkey:
# A board does not link to itself: the place stays empty.
continue
b.cells.append(cell)
for (r, c), item in zip(content, page_items):
cell = rd.cell(item, r, c, f"{path.name}#{key}@{size}", sec.get("style"))
if not cell.blank:
b.cells.append(cell)
if len(pages) > 1:
nxt = key if n + 1 == len(pages) else f"{key}~{n + 2}"
t = rd.cell("$turn", *turn, origin=f"{path.name}#turn@{size}")
t.go = nxt
b.cells.append(t)
boards[bkey] = b
finish(boards, rd.meta["root"], path.name)
ps = rd.page_set(f"{path.stem}-{size}", boards, size)
out.append(ps)
return out
def finish(boards: dict[str, Board], root: str, source: str) -> None:
"""Settle what a board can only know once the whole set is read: its
parent (declared, or the first board that links to it), what "@back"
links to, and the cells a sequence's "@row,col" lines name."""
first_linker: dict[str, str] = {}
for key, b in boards.items():
for c in sorted(b.cells, key=lambda c: (c.row, c.col)):
if c.go and c.go != "@back" and c.go != key:
first_linker.setdefault(c.go, key)
for key, b in boards.items():
if b.parent is None and key != root:
b.parent = first_linker.get(key.split("~")[0], first_linker.get(key))
kept = []
for c in b.cells:
if c.go == "@back":
if b.parent is None:
continue
c.go = b.parent
kept.append(c)
b.cells = kept
for c in b.cells:
if not c.seq:
continue
lines = []
for line in c.seq["lines"]:
if isinstance(line, str) and line.startswith("@"):
r, col = (int(x) for x in line[1:].split(","))
target = next((x for x in b.cells if (x.row, x.col) == (r, col)), None)
if target is None or target.say is None:
raise SourceError(f"{c.origin}: sequence line {line} names no speaking cell")
lines.append({"say": target.say, "cell": [r, col]})
elif isinstance(line, str):
lines.append({"say": line})
else:
lines.append(dict(line))
c.seq = {**c.seq, "lines": lines}
c.say = lines[0]["say"]
def paginate(items: list, content: list[tuple[int, int]], turn: tuple[int, int]) -> list[list]:
"""Split items into pages. A page that continues gives up its turn-the-page
cell; every page keeps the same positions for the same slots."""
if len(items) <= len(content):
return [items]
per_page = [pos for pos in content if pos != turn]
pages = []
for i in range(0, len(items), len(per_page)):
chunk = items[i:i + len(per_page)]
# Re-insert a hole at the turn cell's slot so positions stay fixed.
slotted = []
it = iter(chunk)
for pos in content:
if pos == turn:
slotted.append(None)
else:
slotted.append(next(it, None))
pages.append(slotted)
return pages
def read_all() -> list[PageSet]:
sets: list[PageSet] = []
for name in ("adult", "intent", "themes", "emergent"):
path = SOURCES / f"{name}.yaml"
sets.extend(read_intent(path) if name == "intent" else read_plain(path))
return sets
# ─── Validation ─────────────────────────────────────────────────────────────
def texts_of(cell: Cell) -> list[str]:
out = [cell.label]
for t in (cell.say, cell.add):
if t and t != cell.label:
out.append(t)
if cell.seq:
for line in cell.seq["lines"]:
out.append(line["say"])
if cell.fill and cell.fill.get("hint"):
out.append(cell.fill["hint"])
return [t for t in out if t]
def validate(sets: list[PageSet], symbols: Symbols) -> list[str]:
"""Every problem found, as text. Empty means the sources are sound."""
errs: list[str] = []
for ps in sets:
if ps.root not in ps.boards:
errs.append(f"{ps.key}: root board {ps.root} is missing")
linked: set[str] = set()
for b in ps.boards.values():
seen: set[tuple[int, int]] = set()
for c in b.cells:
if not (0 <= c.row < b.rows and 0 <= c.col < b.cols):
errs.append(f"{c.origin}: outside the {b.rows}x{b.cols} grid")
if (c.row, c.col) in seen:
errs.append(f"{c.origin}: two cells at ({c.row},{c.col})")
seen.add((c.row, c.col))
if c.go is not None:
if c.go not in ps.boards:
errs.append(f"{c.origin}: links to {c.go}, which is not in {ps.key}")
linked.add(c.go)
if c.symbol is not None and symbols.resolve(c.symbol, ps.prefer) is None:
errs.append(f"{c.origin}: symbol concept {c.symbol!r} is not drawn by {ps.prefer}")
if c.seq is not None:
lines = c.seq.get("lines") or []
if len(lines) < 2:
errs.append(f"{c.origin}: a sequence needs two lines or more")
if c.seq.get("order", "in_order") not in ("in_order", "shuffle"):
errs.append(f"{c.origin}: sequence order is in_order or shuffle")
if c.seq.get("at_end", "start_again") not in ("start_again", "stay"):
errs.append(f"{c.origin}: sequence at_end is start_again or stay")
for text in texts_of(c):
for name in PROPRIETARY_NAMES:
if re.search(rf"(?<![A-Za-z]){re.escape(name)}(?![A-Za-z])", text):
errs.append(f"{c.origin}: {text!r} names {name!r}")
for w in CLAIM_WORDS:
if re.search(w, text, re.IGNORECASE):
errs.append(f"{c.origin}: {text!r} reads as a medical claim ({w!r})")
if b.speak_only and any(c.add for c in b.cells):
errs.append(f"{b.origin}: a speak-only board cannot add to the message")
unreachable = set(ps.boards) - linked - {ps.root}
for key in sorted(unreachable):
errs.append(f"{ps.key}: board {key} is not linked from any board")
return errs
# ─── Translations ───────────────────────────────────────────────────────────
@dataclass
class Translation:
text: str | None # the words, when they do not depend on the speaker
forms: dict[str, str] | None # {"f": ..., "m": ...} when they do
class Translations:
"""translations/<lang>.json: only entries both readers agreed on are used."""
def __init__(self) -> None:
self.by_lang: dict[str, dict[str, dict]] = {}
for code in LANGUAGES:
path = TRANSLATIONS / f"{code}.json"
if path.exists():
self.by_lang[code] = json.loads(path.read_text())["strings"]
else:
self.by_lang[code] = {}
def get(self, lang: str, english: str) -> Translation | None:
e = self.by_lang.get(lang, {}).get(english)
if not e or e.get("status") != AGREED:
return None
if e.get("forms"):
return Translation(None, dict(e["forms"]))
return Translation(e["text"], None)
def status(self, lang: str, english: str) -> str:
e = self.by_lang.get(lang, {}).get(english)
return e.get("status", "pending") if e else "pending"
def gendered(self, lang: str, texts: list[str]) -> bool:
return any((t := self.get(lang, x)) is not None and t.forms for x in texts)
def words(self, lang: str, english: str, speaker: str | None) -> str | None:
t = self.get(lang, english)
if t is None:
return None
if t.forms:
return t.forms.get(speaker) if speaker else None
return t.text
# Board descriptions in a localized package: a visible note that some words
# are still in English. Its translation is in each language file under this key.
NOTE_KEY = "Some messages on this board are still in English: their translation has not yet been agreed by two readers."
# ─── Writing OBF ────────────────────────────────────────────────────────────
def bid(ps: PageSet, board: str) -> str:
return str(uuid.uuid5(NAMESPACE, f"{ps.key}/{board}"))
def cid(ps: PageSet, board: str, cell: Cell) -> str:
return str(uuid.uuid5(NAMESPACE, f"{ps.key}/{board}/{cell.row},{cell.col}"))
def board_path(ps: PageSet, board: str) -> str:
return f"boards/{bid(ps, board)}.obf"
def book_pages(ps: PageSet) -> dict[str, int]:
"""Page numbers for a printed book: breadth-first from the root in the
order links appear (row by row), continuation pages straight after their
first page."""
order: list[str] = []
queue = [ps.root]
while queue:
key = queue.pop(0)
if key in order:
continue
order.append(key)
base = key.split("~")[0]
conts = sorted((k for k in ps.boards if k.startswith(base + "~")), key=lambda k: int(k.split("~")[1]))
for k in conts:
if k not in order:
order.append(k)
b = ps.boards[key]
for c in sorted(b.cells, key=lambda c: (c.row, c.col)):
if c.go and c.go not in order and c.go not in queue:
queue.append(c.go)
for k in sorted(ps.boards):
if k not in order:
order.append(k)
return {k: n + 1 for n, k in enumerate(order)}
def same_words(text: str) -> str:
"""Words as a person reads them, to tell whether two cells show the
same (pslib/samewords.py `normal`)."""
import unicodedata
return " ".join(unicodedata.normalize("NFKC", text).casefold().split())
class Package:
"""One .obz: boards in one language (or English with translations)."""
def __init__(self, ps: PageSet, symbols: Symbols, tr: Translations, lang: str | None, speaker: str | None):
self.ps = ps
self.symbols = symbols
self.tr = tr
self.lang = lang # None: the English package with every agreed translation
self.speaker = speaker
self.images: dict[str, bytes] = {}
self.pages = book_pages(ps)
self.counts = {"cells": 0, "texts": 0, "in_english": 0}
@property
def locale(self) -> str:
return "en-US" if self.lang is None else LANGUAGES[self.lang][1]
def words(self, english: str) -> tuple[str, bool]:
"""The words in this package's language, and whether they had to stay
in English."""
if self.lang is None:
return english, False
t = self.tr.words(self.lang, english, self.speaker)
return (english, True) if t is None else (t, False)
@staticmethod
def cell_texts(cell: Cell) -> list[str]:
"""The texts of a cell that must all be in one language: what it
shows, what it says, and the lines of its sequence."""
texts = [cell.label]
spoken = cell.say if cell.say is not None else cell.add
if spoken is not None and spoken != cell.label:
texts.append(spoken)
if cell.seq:
texts += [line["say"] for line in cell.seq["lines"]]
return texts
def localize(self, cell: Cell, lang: str, speaker: str | None) -> dict[str, str] | None:
"""Every text of the cell in `lang`, or None if any is not agreed: a
cell is shown and said in one language, never half in English."""
out = {}
for t in self.cell_texts(cell):
w = self.tr.words(lang, t, speaker)
if w is None:
return None
if w == "" and (t == cell.label or cell.go is None):
# Empty words are only for a link that needs no words in
# this language; a button never shows a blank label.
return None
out[t] = w
return out
def form_entry(self, cell: Cell, loc: dict[str, str]) -> dict:
"""A cell's words in one speaker form: what it shows, says, and the
lines of its sequence."""
spoken = cell.say if cell.say is not None else cell.add
e: dict[str, Any] = {"label": loc[cell.label]}
if spoken is not None and spoken != cell.label:
e["vocalization"] = loc[spoken]
if cell.seq:
e["lines"] = [loc[line["say"]] for line in cell.seq["lines"]]
return e
def button_translations(self, cell: Cell) -> dict:
"""`translations` for the English package: the agreed words of each
language, with the speaker's two forms where they differ."""
spoken = cell.say if cell.say is not None else cell.add
def entry(loc: dict[str, str]) -> dict:
e: dict[str, Any] = {"label": loc[cell.label]}
if spoken is not None and spoken != cell.label:
e["vocalization"] = loc[spoken]
if cell.seq:
e["ext_aurasense_sequence_lines"] = [loc[line["say"]] for line in cell.seq["lines"]]
return e
out: dict[str, Any] = {"en": entry({t: t for t in self.cell_texts(cell)})}
for code in LANGUAGES:
plain = self.localize(cell, code, None)
if plain is not None:
out[code] = entry(plain)
continue
f, m = self.localize(cell, code, "f"), self.localize(cell, code, "m")
if f is not None and m is not None:
out[code] = {"ext_aurasense_speaker_forms": {"f": entry(f), "m": entry(m)}}
return out
def button(self, b: Board, cell: Cell, n: int) -> tuple[dict, tuple[str, dict] | None]:
ps = self.ps
spoken_src = cell.say if cell.say is not None else cell.add
loc = None if self.lang is None else self.localize(cell, self.lang, self.speaker)
def tx(text: str) -> str:
return loc[text] if loc else text
self.counts["cells"] += 1
btn: dict[str, Any] = {"id": cid(ps, b.key, cell), "label": tx(cell.label)}
if spoken_src is not None and cell.go is not None and tx(spoken_src) == "":
# A link's words that this language does not need (the English
# "to" of "I want" + "to" + "watch TV"): the link alone.
spoken_src = None
if spoken_src is not None:
btn["vocalization"] = tx(spoken_src)
prov: dict[str, Any] = {"content": "text", "source": cell.origin, "source_label": btn["label"]}
if spoken_src is not None and btn["vocalization"] != btn["label"]:
prov["source_vocalization"] = btn["vocalization"]
if self.lang is not None:
if loc:
# The words of this language, kept exactly as written (the
# app's own-words cell, Cell::lang): never glossed.
prov["words_locale"] = self.locale
prov["translation"] = STATUS_TEXT
prov["english"] = " | ".join(dict.fromkeys(self.cell_texts(cell)))
else:
self.counts["in_english"] += 1
btn["label_locale"] = "en"
if spoken_src is not None:
btn["vocalization_locale"] = "en"
prov["translation"] = "pending: shown and said in English"
if loc and self.speaker is not None:
# Words that change with who says them carry both forms, so
# the app can put the person's there, or leave the word out
# while they would rather not say (aac-web pagesets.rs).
f, m = self.localize(cell, self.lang, "f"), self.localize(cell, self.lang, "m")
if f is not None and m is not None and f != m:
btn["ext_aurasense_speaker_forms"] = {"f": self.form_entry(cell, f), "m": self.form_entry(cell, m)}
if cell.src:
prov["words_source"] = cell.src
if cell.add is not None:
btn["ext_aurasense_action"] = "buffer"
if cell.go is not None:
target = ps.boards[cell.go]
tname = target.display(self.words(target.name)[0])
btn["load_board"] = {"id": bid(ps, cell.go), "name": tname, "path": board_path(ps, cell.go)}
if spoken_src is not None and cell.say is not None:
# Words and a link: the client adds these words, then opens the
# board. The words themselves, not a flag: a reader that maps the
# button to a link keeps no vocalization, but keeps this field.
btn["ext_aurasense_link_says"] = btn["vocalization"]
btn["ext_aurasense_book_goto"] = self.pages[cell.go]
if cell.do is not None:
btn["action"] = ACTIONS[cell.do]
image = None
if cell.symbol is not None:
btn["ext_aurasense_symbol"] = cell.symbol
if cell.symbol is not None and self.lang is None:
# The pictures travel in the English package only (any OBF app
# can show them); a language's package names each symbol by its
# concept, which the app draws from its own symbol sets.
set_name, sym_id = self.symbols.resolve(cell.symbol, ps.prefer)
image_id = f"{sym_id}.{n}"
path = f"images/{sym_id}.svg"
data, w, h = self.symbols.svg(set_name, sym_id)
self.images[path] = data
image = (image_id, {
"id": image_id,
"content_type": "image/svg+xml",
"width": w,
"height": h,
"path": path,
"symbol": {"set": set_name, "filename": f"{sym_id}.svg"},
"license": self.symbols.license(set_name),
})
btn["image_id"] = image_id
elif cell.symbol is None and cell.want_symbol:
prov["symbol_wanted"] = cell.want_symbol
if cell.color:
btn["background_color"] = hex_to_obf(cell.color)
if cell.border:
btn["border_color"] = hex_to_obf(cell.border)
btn["ext_aurasense_text_color"] = cell.text_color or "#000000"
if cell.fill:
fill = {k: v for k, v in cell.fill.items() if k != "hint"}
if "hint" in cell.fill:
hint, hint_en = self.words(cell.fill["hint"])
fill["hint"] = hint
if hint_en and self.lang is not None:
fill["hint_locale"] = "en"
btn["ext_aurasense_fill_in"] = fill
if cell.seq:
lines = []
for line in cell.seq["lines"]:
out_line: dict[str, Any] = {"say": tx(line["say"])}
if "cell" in line:
# The cell by its place, which never changes (SPEC §3);
# its id may (a set added twice gets new ids).
out_line["at"] = list(line["cell"])
lines.append(out_line)
seq = {
"version": 1,
"lines": lines,
"order": cell.seq.get("order", "in_order"),
"at_end": cell.seq.get("at_end", "start_again"),
"add_to_message": bool(cell.seq.get("add_to_message", False)),
}
if "reset_after_minutes" in cell.seq:
seq["reset_after_minutes"] = cell.seq["reset_after_minutes"]
btn["ext_aurasense_sequence"] = seq
if cell.effect:
btn["ext_aurasense_effect"] = cell.effect
if self.lang is None:
tr = self.button_translations(cell)
if len(tr) > 1:
btn["translations"] = tr
btn["ext_aurasense_locked"] = False
btn["ext_aurasense_provenance"] = prov
return btn, image
def board(self, b: Board, n: int) -> dict:
ps = self.ps
order: list[list[str | None]] = [[None] * b.cols for _ in range(b.rows)]
buttons: list[dict] = []
images: dict[str, dict] = {}
before = self.counts["in_english"]
# One word, one place (pslib/samewords.py; proposed SPEC §4.1 item 5): where
# this language says two of the board's phrases with the same words
# ("No" and "not" are both "no" in Spanish; "I feel" and "I'm
# feeling" both "Me siento"), they show once, at the first's place,
# and the other is hidden in its place (`ext_aurasense_same_word`:
# whatever language the app is in, since these words are written in
# the package's): no cell moves. A slot a partner fills in is told
# apart by what is filled in; a link is never hidden (the build fails
# instead, for a person to settle).
shown: dict[str, str] = {}
for cell in sorted(b.cells, key=lambda c: (c.row, c.col)):
btn, image = self.button(b, cell, n)
words = same_words(btn.get("label") or "")
if self.lang is not None and words and "ext_aurasense_fill_in" not in btn:
if words in shown:
if cell.go is not None or "load_board" in btn:
raise SourceError(f"{b.origin}: in {self.lang}, the link “{btn['label']}” shows the same words as “{shown[words]}”")
btn["ext_aurasense_same_word"] = {"*": shown[words]}
else:
shown[words] = btn["label"]
order[cell.row][cell.col] = btn["id"]
buttons.append(btn)
if image:
images.setdefault(image[0], image[1])
name, name_en = self.words(b.name)
name = b.display(name)
parts = []
if b.description:
parts.append(self.words(b.description)[0])
if self.lang is not None and (name_en or self.counts["in_english"] > before):
# The visible note: some of this board is still in English.
parts.append(self.words(NOTE_KEY)[0])
prov: dict[str, Any] = {
"generator": GENERATOR,
"source_locale": "en",
"source_name": name,
"board_type": "Grid",
"created_at": f"{ps.version}T00:00:00+00:00",
"set": ps.key,
"source": b.origin,
}
if self.lang is not None:
if name_en:
prov["name_locale"] = "en"
else:
prov["words_locale"] = self.locale
prov["english_name"] = b.name
out: dict[str, Any] = {
"format": OBF_FORMAT,
"id": bid(ps, b.key),
"locale": self.locale,
"name": name,
"license": ps.license,
"grid": {"rows": b.rows, "columns": b.cols, "order": order},
"buttons": buttons,
"images": [images[k] for k in sorted(images)],
"sounds": [],
"ext_aurasense_locked": False,
"ext_aurasense_provenance": prov,
}
if parts:
out["description_html"] = "".join(f"<p>{html.escape(p)}</p>" for p in parts)
behaviour = {}
if b.popup:
behaviour["after_choice"] = "return"
if b.speak_only:
behaviour["message"] = "speak_only"
if behaviour:
out["ext_aurasense_behaviour"] = behaviour
out["ext_aurasense_book"] = {"page": self.pages[b.key]}
return out
def build(self) -> bytes:
ps = self.ps
keys = [ps.root] + sorted((k for k in ps.boards if k != ps.root), key=lambda k: self.pages[k])
files: list[tuple[str, bytes]] = []
board_paths = {}
image_paths: dict[str, str] = {}
for n, key in enumerate(keys, start=1):
v = self.board(ps.boards[key], n)
for img in v["images"]:
image_paths[img["id"]] = img["path"]
board_paths[bid(ps, key)] = board_path(ps, key)
files.append((board_path(ps, key), json.dumps(v, ensure_ascii=False, separators=(",", ":")).encode()))
for path in sorted(self.images):
files.append((path, self.images[path]))
manifest = {
"format": OBF_FORMAT,
"root": board_path(ps, ps.root),
"paths": {"boards": board_paths, "images": dict(sorted(image_paths.items())), "sounds": {}},
"ext_aurasense_page_set": {
"key": ps.key,
"name": ps.name,
"version": ps.version,
"locale": self.locale,
"speaker_forms": self.speaker,
"generator": GENERATOR,
},
}
files.insert(0, ("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=1).encode()))
return stored_zip(files)
def stored_zip(files: list[tuple[str, bytes]]) -> bytes:
"""A zip of uncompressed entries with a fixed date (what the app's own
importer reads without inflating)."""
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_STORED) as z:
for name, data in files:
info = zipfile.ZipInfo(name, date_time=ZIP_DATE)
info.compress_type = zipfile.ZIP_STORED
info.external_attr = 0o644 << 16
info.create_system = 0
z.writestr(info, data)
return buf.getvalue()
# ─── Strings, catalogue and reports ─────────────────────────────────────────
def all_strings(sets: list[PageSet]) -> dict[str, dict]:
"""Every English text a translator must see, with where it is used."""
out: dict[str, dict] = {}
def add(text: str, where: str, role: str, note: str | None = None) -> None:
e = out.setdefault(text, {"uses": [], "roles": [], "notes": []})
if where not in e["uses"] and len(e["uses"]) < 4:
e["uses"].append(where)
if role not in e["roles"]:
e["roles"].append(role)
if note and note not in e["notes"]:
e["notes"].append(note)
for ps in sets:
for b in ps.boards.values():
add(b.name, f"{ps.family}: board name", "board name")
if b.description:
add(b.description, f"{ps.family} > {b.name}", "a note to the partner, shown with the board")
for c in b.cells:
where = f"{ps.family} > {b.name}"
add(c.label, where, "label (shown on the cell)" if (c.say or c.add) != c.label else "label and spoken message", c.note)
spoken = c.say if c.say is not None else c.add
if spoken and spoken != c.label:
add(spoken, where, "spoken message" if c.say else "words added to the message", c.note)
if c.seq:
for line in c.seq["lines"]:
add(line["say"], f"{where} > {c.label}", "a line of a sequence said one press at a time", c.note)
if c.fill and c.fill.get("hint"):
add(c.fill["hint"], where, "hint to the partner who sets the cell up")
add(NOTE_KEY, "every board", "a note shown to the partner")
return dict(sorted(out.items()))
FAMILIES = {
"adult": {
"style": "Adults with acquired conditions",
"offer": "Adults whose talking has changed (aphasia, ALS/MND, primary progressive aphasia, brain injury, "
"Parkinson's). Offer \"Make Simple page my home\" as a later choice.",
},
"intent": {
"style": "Intent-first vocabulary",
"offer": "Anyone who starts from what they want to do; choose 12, 20 or 40 cells a page. Also printable "
"as a book a partner reads from.",
},
"themes": {
"style": "Themed phrase grids",
"offer": "Readers who want the likely messages of a situation on one page.",
},
"emergent": {
"style": "First switches and single messages",
"offer": "First communicators and first switch users; the index pages are for the partner.",
},
}
def catalogue(sets: list[PageSet], built: dict[str, dict], tr: Translations, strings: dict) -> dict:
"""The catalogue a page-set chooser reads: every set, its files in every
language (with size and checksum), and how far each language goes."""
langs = {}
for code, (name, locale) in LANGUAGES.items():
agreed = sum(1 for s in strings if tr.status(code, s) == AGREED)
gendered = any((t := tr.get(code, s)) is not None and t.forms for s in strings)
langs[code] = {"language": name, "locale": locale, "agreed_texts": agreed, "texts": len(strings),
"speaker_forms": gendered, "status": STATUS_TEXT}
families = []
for fam, meta in FAMILIES.items():
members = [ps for ps in sets if ps.family == fam]
families.append({
"family": fam,
"style": meta["style"],
"name": members[0].name.replace(" ({size})", "").split(" (")[0],
"description": (members[0].description.replace(
f"{members[0].size} cells", ", ".join(str(ps.size) for ps in members[:-1]) + f" or {members[-1].size} cells")
if members[0].size else members[0].description),
"sets": [ps.key for ps in members],
"sizes": [ps.size for ps in members] if members[0].size else None,
"offer": meta["offer"],
})
return {
"version": 1,
"generator": GENERATOR,
"about": ("Phrase page sets for aac.aurasense.ai/app and any Open Board Format app, built from "
"tools/pagesets/phrases/sources. English is the source; a translation is used only where two "
"independent model readers agreed on it, and a native speaker's review is still to come. A cell "
"without an agreed translation stays in English and says so (label_locale \"en\"). Where "
"files[lang] has f and m, choose by the person's \"words about myself\" setting."),
"license": {"type": "CC-BY-SA-4.0", "url": "https://creativecommons.org/licenses/by-sa/4.0/",
"author": "AuraSense Tech Corporation and the open-standards AAC contributors",
"file": "LICENSE.md"},
"families": families,
"sets": [
{
"key": ps.key,
"family": ps.family,
"name": ps.name,
"description": ps.description,
"size": ps.size,
"boards": len(ps.boards),
"cells": sum(len(b.cells) for b in ps.boards.values()),
"files": built[ps.key],
}
for ps in sets
],
"languages": langs,
"pending_languages": PENDING_LANGUAGES,
}
# The families as the page-set catalogue (tools/pagesets/catalog.py) names
# them: its generator contract is `build.py --out DIR` writing
# DIR/families.json beside the packages.
CATALOGUE_FAMILIES = {
"adult": {"key": "phrases-adult", "name": "Adult conversation"},
"intent": {"key": "phrases-intent", "name": "Intent first"},
"themes": {"key": "phrases-themes", "name": "Themed phrases"},
"emergent": {"key": "phrases-emergent", "name": "First switches and messages"},
}
ATTRIBUTION = ("Phrase page sets \u00a9 2026 AuraSense Tech Corporation and the open-standards AAC contributors, "
"CC BY-SA 4.0. Symbols are named from Mulberry Symbols (Steve Lee; designed by Claire Barge) and "
"OpenMoji, each CC BY-SA 4.0, with their own attributions on each image. The 40-cell intent-first "
"start page uses 21 words of the Universal Core vocabulary (Center for Literacy and Disability "
"Studies, UNC Chapel Hill; Project Core), words only.")
LANGUAGE_NOTE = ("Written in English. In Spanish, French, German, Portuguese, Italian, Chinese, Japanese, Arabic, Hindi "
"and Russian the set comes in that language, where two independent AI models agreed on each "
"translation (a native speaker's review is still to come); anything not agreed stays in English "
"and says so. In other languages it is in English for now.")
def families(sets: list[PageSet], built: dict[str, dict]) -> dict:
"""The sets as families of the page-set catalogue (catalog.py's generator
contract): one package per set, language and speaker form, each with its
language (`lang`), and `gender` "f" / "m" where the words change with the
speaker's; `size` is the cells a page for the intent-first sizes."""
out = []
for fam, meta in FAMILIES.items():
cat = CATALOGUE_FAMILIES[fam]
members = [ps for ps in sets if ps.family == fam]
packages = []
cells: dict[str, list[int]] = {}
for ps in members:
root = ps.boards[ps.root]
for code, entry in built[ps.key].items():
forms = entry.items() if "file" not in entry else [(None, entry)]
for gender, f in forms:
key = f"{cat['key']}-{ps.key.removeprefix(fam).lstrip('-') or 'set'}-{code}" + (f"-{gender}" if gender else "")
packages.append({
"key": key.replace("-set-", "-"),
"file": f["file"],
"lang": code,
"gender": gender,
"variant": "full",
"size": str(ps.size) if ps.size else "",
"rows": root.rows,
"cols": root.cols,
"label": f"{ps.size} cells a page" if ps.size else ps.name,
"description": ps.description,
"home": bid(ps, ps.root),
"boards": len(ps.boards),
"cells": f["cells"],
"cells_in_english": f["cells_in_english"],
})
if code != "en" and gender in (None, "f"):
c = cells.setdefault(code, [0, 0])
c[0] += f["cells"]
c[1] += f["cells_in_english"]
languages = {code: {"status": "GlossOnly", "glossed": round(1 - english / total, 3)}
for code, (total, english) in sorted(cells.items()) if total}
sources = [{"name": "Phrases written for this project (AuraSense; open-standards AAC)", "licence": "CC-BY-SA-4.0"},
{"name": "Mulberry Symbols, OpenMoji", "licence": "CC-BY-SA-4.0"}]
if fam == "intent":
sources.insert(1, {"name": "Universal Core vocabulary (UNC CLDS / Project Core), 21 words, words only",
"licence": "CC-BY-SA-4.0"})
description = (members[0].description.replace(
f"{members[0].size} cells", ", ".join(str(ps.size) for ps in members[:-1]) + f" or {members[-1].size} cells")
if members[0].size else members[0].description)
out.append({
"key": cat["key"],
"name": cat["name"],
"summary": meta["style"],
"description": description,
"chooser": "phrases",
"version": members[0].version,
"licence": "CC-BY-SA-4.0",
"attribution": ATTRIBUTION,
"sources": sources,
"languages": languages,
"language_note": LANGUAGE_NOTE,
"offer": meta["offer"],
"packages": packages,
})
return {"families": out}
def report_symbols(sets: list[PageSet], symbols: Symbols) -> str:
wanted: dict[str, list[str]] = {}
used: dict[str, set[str]] = {}
for ps in sets:
for b in ps.boards.values():
for c in b.cells:
if c.want_symbol:
wanted.setdefault(c.want_symbol, [])
where = f"{ps.family}: {c.label}"
if where not in wanted[c.want_symbol]:
wanted[c.want_symbol].append(where)
if c.symbol:
s, _ = symbols.resolve(c.symbol, ps.prefer)
used.setdefault(c.symbol, set()).add(s)
lines = [
"# Symbols for the phrase page sets",
"",
"Generated by `build.py --reports`; do not edit.",
"",
f"{len(used)} concepts of the app's symbol index are used. {len(wanted)} concepts are wanted but the "
"index has no symbol for them yet; those cells show their words alone (an adult set may use the "
"person's own photo instead). Add them to `tools/symbols` (a confident match only: a wrong picture "
"is worse than none) and name them in the sources with `symbol:` once they exist.",
"",
"## Wanted (no symbol yet)",
"",
"| Concept | Used for |",
"|---|---|",
]
for concept in sorted(wanted):
uses = wanted[concept]
lines.append(f"| `{concept}` | {'; '.join(uses[:4])}{' …' if len(uses) > 4 else ''} |")
lines += ["", "## Used", "", "| Concept | Set |", "|---|---|"]
for concept in sorted(used):
lines.append(f"| `{concept}` | {', '.join(sorted(used[concept]))} |")
return "\n".join(lines) + "\n"
def report_translations(strings: dict, tr: Translations) -> str:
lines = [
"# Translation status of the phrase page sets",
"",
"Generated by `build.py --reports`; do not edit. Each text was drafted, then back-translated by a "
"second reader who never saw the English, and the two compared. Only texts both agreed on are used "
f"(\"{STATUS_TEXT}\"); the rest stay in English, marked as such in every package.",
"",
"| Language | Agreed | Disputed | Not yet translated | Speaker-gendered |",
"|---|---|---|---|---|",
]
for code, (name, _) in LANGUAGES.items():
st = [tr.status(code, s) for s in strings]
gendered = sum(1 for s in strings if (t := tr.get(code, s)) is not None and t.forms)
lines.append(f"| {name} (`{code}`) | {st.count(AGREED)} / {len(st)} | {st.count('disputed')} | "
f"{st.count('pending')} | {gendered} |")
lines += ["", f"Pending (English only for now): {', '.join(f'`{c}`' for c in PENDING_LANGUAGES)}.", ""]
for code, (name, _) in LANGUAGES.items():
disputed = [s for s in strings if tr.status(code, s) == "disputed"]
if disputed:
lines += [f"## {name}: disputed, shown in English", ""]
for s in disputed:
e = tr.by_lang[code][s]
reason = (e.get("notes") or "").replace("\n", " ")
lines.append(f"- {s!r}: {reason}")
lines.append("")
return "\n".join(lines) + "\n"
def report_sets(sets: list[PageSet], built: dict[str, dict]) -> str:
lines = ["# Phrase page sets: contents", "", "Generated by `build.py --reports`; do not edit.", ""]
for ps in sets:
cells = sum(len(b.cells) for b in ps.boards.values())
lines += [f"## {ps.name} (`{ps.key}.obz`)", "", ps.description, "",
f"{len(ps.boards)} boards, {cells} cells.", "", "| Page | Board | Grid | Cells | Behaviour |",
"|---|---|---|---|---|"]
pages = book_pages(ps)
for key in sorted(ps.boards, key=lambda k: pages[k]):
b = ps.boards[key]
beh = ", ".join(x for x in ("closes after a choice" if b.popup else "",
"speaks without building a message" if b.speak_only else "") if x)
lines.append(f"| {pages[key]} | {b.display(b.name)} | {b.rows}×{b.cols} | {len(b.cells)} | {beh} |")
lines.append("")
return "\n".join(lines) + "\n"
# ─── Commands ───────────────────────────────────────────────────────────────
def file_entry(name: str, data: bytes, pkg: "Package") -> dict:
return {
"file": name,
"bytes": len(data),
"sha256": hashlib.sha256(data).hexdigest(),
"cells": pkg.counts["cells"],
"cells_in_english": pkg.counts["in_english"] if pkg.lang else 0,
}
def build_outputs(hash_names: bool = False) -> tuple[dict[str, bytes], dict[Path, bytes]]:
"""Every file of a build, deterministic: (the packages, catalogue and
licence, by path relative to the output directory; the review reports,
by path in the repository)."""
symbols = Symbols()
sets = read_all()
errs = validate(sets, symbols)
if errs:
raise SourceError("\n".join(errs))
tr = Translations()
strings = all_strings(sets)
out: dict[str, bytes] = {}
built: dict[str, dict] = {}
def put(stem: str, pkg: Package) -> dict:
data = pkg.build()
name = f"{stem}.{hashlib.sha256(data).hexdigest()[:12]}.obz" if hash_names else f"{stem}.obz"
out[name] = data
return file_entry(name, data, pkg)
for ps in sets:
files: dict[str, Any] = {"en": put(ps.key, Package(ps, symbols, tr, None, None))}
texts = [t for b in ps.boards.values() for c in b.cells for t in texts_of(c)] + [b.name for b in ps.boards.values()]
texts += [b.description for b in ps.boards.values() if b.description]
for code in LANGUAGES:
if tr.gendered(code, texts):
files[code] = {g: put(f"{code}/{ps.key}.{g}", Package(ps, symbols, tr, code, g)) for g in ("f", "m")}
else:
files[code] = put(f"{code}/{ps.key}", Package(ps, symbols, tr, code, None))
built[ps.key] = files
out["index.json"] = (json.dumps(catalogue(sets, built, tr, strings), ensure_ascii=False, indent=1) + "\n").encode()
out["families.json"] = (json.dumps(families(sets, built), ensure_ascii=False, indent=1) + "\n").encode()
out["LICENSE.md"] = (HERE / "LICENSE.md").read_bytes()
reports = {
REPORTS / "symbols.md": report_symbols(sets, symbols).encode(),
REPORTS / "translation-status.md": report_translations(strings, tr).encode(),
REPORTS / "sets.md": report_sets(sets, built).encode(),
}
return out, reports
def differences(want: dict, have_dir: Path | None, have: dict | None = None) -> list[str]:
"""Files of `want` that are missing or different in `have_dir` (or the
in-memory `have`), and .obz files there that `want` does not have."""
diffs = []
for name, data in want.items():
if have is not None:
got = have.get(name)
else:
path = Path(name) if isinstance(name, Path) else have_dir / name
got = path.read_bytes() if path.exists() else None
if got != data:
diffs.append(str(name))
if have is None and have_dir is not None and have_dir.exists():
for old in have_dir.rglob("*.obz"):
if str(old.relative_to(have_dir)) not in want:
diffs.append(f"{old.relative_to(have_dir)} (not built any more)")
return diffs
def write(files: dict, base: Path | None) -> None:
for name, data in files.items():
path = Path(name) if base is None else base / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--out", type=Path,
help="write the packages, index.json, families.json and LICENSE.md into this directory")
ap.add_argument("--hash-names", action="store_true", help="name packages <name>.<sha256-12>.obz")
ap.add_argument("--check", action="store_true",
help="fail if the sources are invalid, two builds differ, reports/ is stale, or (with --out) "
"the directory differs from a fresh build; writes nothing")
ap.add_argument("--reports", action="store_true", help="rewrite reports/ (committed review aids)")
ap.add_argument("--strings", action="store_true", help="write work/strings.json for translators")
args = ap.parse_args()
if not (args.out or args.check or args.reports or args.strings):
ap.print_help()
return 2
try:
if args.strings:
sets = read_all()
errs = validate(sets, Symbols())
if errs:
raise SourceError("\n".join(errs))
WORK.mkdir(exist_ok=True)
strings = all_strings(sets)
(WORK / "strings.json").write_text(json.dumps(strings, ensure_ascii=False, indent=1) + "\n")
print(f"{len(strings)} strings -> {WORK / 'strings.json'}")
if not (args.out or args.check or args.reports):
return 0
out, reports = build_outputs(args.hash_names)
except SourceError as e:
print(f"error:\n{e}", file=sys.stderr)
return 1
if args.check:
again, _ = build_outputs(args.hash_names)
problems = [f"not reproducible: {d}" for d in differences(out, None, again)]
problems += [f"stale (run build.py --reports): {d}" for d in differences(reports, None)]
if args.out:
problems += [f"stale in {args.out}: {d}" for d in differences(out, args.out)]
if problems:
print("\n".join(problems), file=sys.stderr)
return 1
print(f"ok: {sum(1 for n in out if n.endswith('.obz'))} packages reproducible"
+ (f", {args.out} up to date" if args.out else "") + ", reports up to date")
return 0
if args.reports:
write(reports, None)
print(f"reports written: {', '.join(str(p.relative_to(AAC_RS)) for p in reports)}")
if args.out:
if args.out.exists():
for old in args.out.rglob("*.obz"):
if str(old.relative_to(args.out)) not in out:
old.unlink()
write(out, args.out)
total = sum(len(d) for d in out.values())
print(f"{sum(1 for n in out if n.endswith('.obz'))} packages, index.json, families.json and LICENSE.md -> {args.out} "
f"({total / 1e6:.1f} MB)")
return 0
if __name__ == "__main__":
sys.exit(main())