277 lines
11 KiB
Python
277 lines
11 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
# /// script
|
||
|
|
# requires-python = ">=3.10"
|
||
|
|
# ///
|
||
|
|
"""Generate src/tables/mac.rs from the Apple mapping tables in data/apple/.
|
||
|
|
|
||
|
|
The inputs are the canonical Apple character set tables published by the
|
||
|
|
Unicode Consortium (https://www.unicode.org/Public/MAPPINGS/VENDORS/APPLE/),
|
||
|
|
vendored in ``data/apple/``. Each single-byte encoding becomes a full
|
||
|
|
256-entry ``[Option<char>; 256]`` table.
|
||
|
|
|
||
|
|
The vendored files carry the *modern* (post-Euro) mappings; the pre-Euro
|
||
|
|
revisions are reconstructed from the per-encoding deltas documented in each
|
||
|
|
file's change history (the Mac OS 8.5/9.0/9.2.2 Euro rollout landed at
|
||
|
|
different byte positions per encoding, which is why these are configured
|
||
|
|
data with invariant checks rather than hand-edited tables). Mac OS
|
||
|
|
Ukrainian, retired as a separate character set in Mac OS 9.0, is likewise
|
||
|
|
derived from CYRILLIC.TXT plus its documented delta (UKRAINE.TXT is a
|
||
|
|
notes-only stub).
|
||
|
|
|
||
|
|
Every delta records the scalar the vendored file is expected to hold, so
|
||
|
|
regeneration fails loudly if an upstream file ever changes underneath us.
|
||
|
|
|
||
|
|
Run from the crate root: ``uv run tools/gen_tables.py``
|
||
|
|
(needs ``rustfmt`` on PATH).
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import subprocess
|
||
|
|
from collections.abc import Mapping, Sequence
|
||
|
|
from dataclasses import dataclass, field
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
DATA_DIR = Path(__file__).resolve().parent.parent / "data" / "apple"
|
||
|
|
OUTPUT_PATH = Path(__file__).resolve().parent.parent / "src" / "tables" / "mac.rs"
|
||
|
|
|
||
|
|
GENERATED_NOTE = """\
|
||
|
|
//! Generated by `tools/gen_tables.py` — do not edit by hand.
|
||
|
|
//!
|
||
|
|
//! Source: the Unicode Consortium's Apple mapping tables, vendored in
|
||
|
|
//! `data/apple/`. `*_CLASSIC` tables are the pre-Euro revisions,
|
||
|
|
//! reconstructed from the deltas documented in each file's change history.
|
||
|
|
"""
|
||
|
|
|
||
|
|
SURROGATE_RANGE = range(0xD800, 0xE000)
|
||
|
|
|
||
|
|
# Apple's files list 0x20-0x7E and 0x80-0xFF; the C0 controls and DELETE are
|
||
|
|
# omitted because they map to themselves.
|
||
|
|
CONTROL_BYTES = frozenset(range(0x20)) | {0x7F}
|
||
|
|
LISTED_BYTES = frozenset(range(0x100)) - CONTROL_BYTES
|
||
|
|
|
||
|
|
EURO = 0x20AC
|
||
|
|
CURRENCY_SIGN = 0x00A4
|
||
|
|
SOFT_HYPHEN = 0x00AD
|
||
|
|
|
||
|
|
# The Mac OS 8.5 Euro rollout for the Roman regional family: 0xDB flipped
|
||
|
|
# from CURRENCY SIGN to EURO SIGN.
|
||
|
|
ROMAN_FAMILY_EURO_DELTA: Mapping[int, tuple[int, int | None]] = {
|
||
|
|
0xDB: (EURO, CURRENCY_SIGN),
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
class TableError(Exception):
|
||
|
|
"""A mapping table failed to parse or violated an invariant."""
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass(frozen=True)
|
||
|
|
class EncodingSpec:
|
||
|
|
"""One generated encoding.
|
||
|
|
|
||
|
|
Attributes:
|
||
|
|
const: Rust constant name for the modern table.
|
||
|
|
file: Mapping file name under ``data/apple/``.
|
||
|
|
base_delta: Byte to ``(expected_modern_scalar, scalar_or_None)``
|
||
|
|
applied to *derive* this encoding from the file (Mac OS
|
||
|
|
Ukrainian is CYRILLIC.TXT with 0xFF back-flipped to CURRENCY
|
||
|
|
SIGN). ``None`` marks the byte undefined.
|
||
|
|
classic_delta: Byte to ``(expected_modern_scalar, scalar_or_None)``
|
||
|
|
producing an additional ``*_CLASSIC`` (pre-Euro) table.
|
||
|
|
doc: Override for the generated table's doc comment (used where the
|
||
|
|
table is not simply the file's contents, e.g. Ukrainian).
|
||
|
|
"""
|
||
|
|
|
||
|
|
const: str
|
||
|
|
file: str
|
||
|
|
base_delta: Mapping[int, tuple[int, int | None]] = field(default_factory=dict)
|
||
|
|
classic_delta: Mapping[int, tuple[int, int | None]] = field(default_factory=dict)
|
||
|
|
doc: str | None = None
|
||
|
|
|
||
|
|
|
||
|
|
SPECS: Sequence[EncodingSpec] = (
|
||
|
|
EncodingSpec("MAC_ROMAN", "ROMAN.TXT", classic_delta=ROMAN_FAMILY_EURO_DELTA),
|
||
|
|
# Mac OS 9.2.2 moved SOFT HYPHEN from 0x9C to the previously undefined
|
||
|
|
# 0xFF and put EURO SIGN at 0x9C.
|
||
|
|
EncodingSpec(
|
||
|
|
"MAC_GREEK",
|
||
|
|
"GREEK.TXT",
|
||
|
|
classic_delta={0x9C: (EURO, SOFT_HYPHEN), 0xFF: (SOFT_HYPHEN, None)},
|
||
|
|
),
|
||
|
|
# Mac OS 9.0 merged Cyrillic with Ukrainian (0xA2/0xB6 became GHE WITH
|
||
|
|
# UPTURN) and put EURO SIGN at 0xFF.
|
||
|
|
EncodingSpec(
|
||
|
|
"MAC_CYRILLIC",
|
||
|
|
"CYRILLIC.TXT",
|
||
|
|
classic_delta={
|
||
|
|
0xA2: (0x0490, 0x00A2),
|
||
|
|
0xB6: (0x0491, 0x2202),
|
||
|
|
0xFF: (EURO, CURRENCY_SIGN),
|
||
|
|
},
|
||
|
|
),
|
||
|
|
EncodingSpec("MAC_CENTRAL_EUR_ROMAN", "CENTEURO.TXT"),
|
||
|
|
EncodingSpec("MAC_TURKISH", "TURKISH.TXT"),
|
||
|
|
EncodingSpec("MAC_CROATIAN", "CROATIAN.TXT", classic_delta=ROMAN_FAMILY_EURO_DELTA),
|
||
|
|
EncodingSpec("MAC_ICELANDIC", "ICELAND.TXT", classic_delta=ROMAN_FAMILY_EURO_DELTA),
|
||
|
|
EncodingSpec("MAC_ROMANIAN", "ROMANIAN.TXT", classic_delta=ROMAN_FAMILY_EURO_DELTA),
|
||
|
|
EncodingSpec("MAC_CELTIC", "CELTIC.TXT", classic_delta=ROMAN_FAMILY_EURO_DELTA),
|
||
|
|
EncodingSpec("MAC_GAELIC", "GAELIC.TXT", classic_delta=ROMAN_FAMILY_EURO_DELTA),
|
||
|
|
# The pre-9.0 Ukrainian currency sign variant: modern Cyrillic except
|
||
|
|
# 0xFF stayed CURRENCY SIGN (per the notes in UKRAINE.TXT).
|
||
|
|
EncodingSpec(
|
||
|
|
"MAC_UKRAINIAN",
|
||
|
|
"CYRILLIC.TXT",
|
||
|
|
base_delta={0xFF: (EURO, CURRENCY_SIGN)},
|
||
|
|
doc="CYRILLIC.TXT with 0xFF flipped back to CURRENCY SIGN — the "
|
||
|
|
"pre-9.0 Ukrainian variant per the notes in UKRAINE.TXT.",
|
||
|
|
),
|
||
|
|
EncodingSpec("MAC_INUIT", "INUIT.TXT"),
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def parse_table(path: Path) -> dict[int, int]:
|
||
|
|
"""Parse one Apple mapping file into a full 256-entry byte-to-scalar map.
|
||
|
|
|
||
|
|
The omitted C0 controls and DELETE are filled in as identity mappings.
|
||
|
|
|
||
|
|
Raises:
|
||
|
|
TableError: If the file is unreadable, lists a byte twice or outside
|
||
|
|
the expected set, maps to anything but one BMP scalar, or is not
|
||
|
|
ASCII-transparent over 0x20-0x7E.
|
||
|
|
"""
|
||
|
|
try:
|
||
|
|
text = path.read_text(encoding="ascii")
|
||
|
|
except (OSError, UnicodeDecodeError) as error:
|
||
|
|
raise TableError(f"{path.name}: unreadable mapping table") from error
|
||
|
|
table = {byte: byte for byte in CONTROL_BYTES}
|
||
|
|
seen: set[int] = set()
|
||
|
|
for line in text.splitlines():
|
||
|
|
line = line.strip()
|
||
|
|
if not line.startswith("0x"):
|
||
|
|
continue
|
||
|
|
columns = line.split("\t")
|
||
|
|
try:
|
||
|
|
byte = int(columns[0], 16)
|
||
|
|
except ValueError as error:
|
||
|
|
raise TableError(f"{path.name}: malformed byte {columns[0]!r}") from error
|
||
|
|
if byte in seen:
|
||
|
|
raise TableError(f"{path.name}: byte {byte:#04x} listed twice")
|
||
|
|
seen.add(byte)
|
||
|
|
if byte not in LISTED_BYTES:
|
||
|
|
raise TableError(f"{path.name}: unexpected byte {byte:#04x}")
|
||
|
|
if len(columns) < 2 or not columns[1].startswith("0x"):
|
||
|
|
raise TableError(f"{path.name}: byte {byte:#04x} has no mapping")
|
||
|
|
scalar_text = columns[1]
|
||
|
|
if "+" in scalar_text:
|
||
|
|
raise TableError(f"{path.name}: byte {byte:#04x} maps to a sequence")
|
||
|
|
try:
|
||
|
|
scalar = int(scalar_text, 16)
|
||
|
|
except ValueError as error:
|
||
|
|
raise TableError(
|
||
|
|
f"{path.name}: malformed scalar {scalar_text!r}"
|
||
|
|
) from error
|
||
|
|
if scalar in SURROGATE_RANGE or scalar > 0xFFFF:
|
||
|
|
raise TableError(f"{path.name}: scalar {scalar:#06x} not a BMP char")
|
||
|
|
if 0x20 <= byte <= 0x7E and scalar != byte:
|
||
|
|
raise TableError(f"{path.name}: byte {byte:#04x} is not ASCII")
|
||
|
|
table[byte] = scalar
|
||
|
|
if seen != LISTED_BYTES:
|
||
|
|
missing = sorted(LISTED_BYTES - seen)
|
||
|
|
raise TableError(f"{path.name}: bytes never listed: {missing}")
|
||
|
|
return table
|
||
|
|
|
||
|
|
|
||
|
|
def apply_delta(
|
||
|
|
name: str, table: Mapping[int, int], delta: Mapping[int, tuple[int, int | None]]
|
||
|
|
) -> dict[int, int | None]:
|
||
|
|
"""Apply a revision delta, verifying the modern scalars it replaces.
|
||
|
|
|
||
|
|
Raises:
|
||
|
|
TableError: If the vendored table does not hold the expected modern
|
||
|
|
scalar at a delta byte — i.e. upstream data changed and the
|
||
|
|
configured delta no longer describes it.
|
||
|
|
"""
|
||
|
|
result: dict[int, int | None] = dict(table)
|
||
|
|
for byte, (expected_modern, replacement) in delta.items():
|
||
|
|
if table.get(byte) != expected_modern:
|
||
|
|
raise TableError(
|
||
|
|
f"{name}: delta expects U+{expected_modern:04X} at {byte:#04x}, "
|
||
|
|
f"file has U+{table[byte]:04X}"
|
||
|
|
)
|
||
|
|
result[byte] = replacement
|
||
|
|
return result
|
||
|
|
|
||
|
|
|
||
|
|
def check_bijection(name: str, table: Mapping[int, int | None]) -> None:
|
||
|
|
"""Require every mapped scalar to have exactly one byte.
|
||
|
|
|
||
|
|
Raises:
|
||
|
|
TableError: If two bytes map to the same scalar.
|
||
|
|
"""
|
||
|
|
scalars = [scalar for scalar in table.values() if scalar is not None]
|
||
|
|
if len(scalars) != len(set(scalars)):
|
||
|
|
raise TableError(f"{name}: table is not a bijection")
|
||
|
|
|
||
|
|
|
||
|
|
def rust_char(scalar: int) -> str:
|
||
|
|
"""Render a Unicode scalar as a Rust char literal."""
|
||
|
|
ch = chr(scalar)
|
||
|
|
if ch.isprintable() and ch not in ("'", "\\"):
|
||
|
|
return f"'{ch}'"
|
||
|
|
return f"'\\u{{{scalar:04X}}}'"
|
||
|
|
|
||
|
|
|
||
|
|
def render_table(const: str, doc: str, table: Mapping[int, int | None]) -> str:
|
||
|
|
"""Render one encoding as an ``[Option<char>; 256]``."""
|
||
|
|
lines = [f"/// {doc}", f"pub(crate) const {const}: [Option<char>; 256] = ["]
|
||
|
|
for row_start in range(0, 256, 8):
|
||
|
|
entries = []
|
||
|
|
for byte in range(row_start, row_start + 8):
|
||
|
|
scalar = table[byte]
|
||
|
|
entries.append("None" if scalar is None else f"Some({rust_char(scalar)})")
|
||
|
|
lines.append(
|
||
|
|
f" {', '.join(entries)}, // {row_start:#04x}-{row_start + 7:#04x}"
|
||
|
|
)
|
||
|
|
lines.append("];\n")
|
||
|
|
return "\n".join(lines)
|
||
|
|
|
||
|
|
|
||
|
|
def render_spec(spec: EncodingSpec) -> list[str]:
|
||
|
|
"""Render a spec's modern table, plus its classic table if it has one.
|
||
|
|
|
||
|
|
Raises:
|
||
|
|
TableError: If parsing, a delta, or a bijection check fails.
|
||
|
|
"""
|
||
|
|
file_table = parse_table(DATA_DIR / spec.file)
|
||
|
|
modern = apply_delta(spec.const, file_table, spec.base_delta)
|
||
|
|
check_bijection(spec.const, modern)
|
||
|
|
doc = spec.doc or f"{spec.file}, byte value to Unicode scalar."
|
||
|
|
rendered = [render_table(spec.const, doc, modern)]
|
||
|
|
if spec.classic_delta:
|
||
|
|
if spec.base_delta:
|
||
|
|
raise TableError(f"{spec.const}: base and classic deltas both set")
|
||
|
|
classic = apply_delta(spec.const, file_table, spec.classic_delta)
|
||
|
|
check_bijection(f"{spec.const}_CLASSIC", classic)
|
||
|
|
rendered.append(
|
||
|
|
render_table(
|
||
|
|
f"{spec.const}_CLASSIC",
|
||
|
|
f"{spec.file} at the pre-Euro revision.",
|
||
|
|
classic,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
return rendered
|
||
|
|
|
||
|
|
|
||
|
|
def main() -> None:
|
||
|
|
"""Regenerate src/tables/mac.rs from every configured encoding."""
|
||
|
|
rendered = [GENERATED_NOTE]
|
||
|
|
for spec in SPECS:
|
||
|
|
rendered.extend(render_spec(spec))
|
||
|
|
OUTPUT_PATH.write_text("\n".join(rendered), encoding="utf-8")
|
||
|
|
subprocess.run(["rustfmt", "--edition", "2021", str(OUTPUT_PATH)], check=True)
|
||
|
|
print(f"wrote {OUTPUT_PATH}")
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
main()
|