#!/usr/bin/env python3 # /// script # requires-python = ">=3.10" # /// """Generate src/tables/ from the Microsoft mapping tables in data/. The inputs are the canonical Microsoft code page tables published by the Unicode Consortium (https://www.unicode.org/Public/MAPPINGS/VENDORS/MICSFT/): ``PC/`` (vendored in ``data/pc/``) for the single-byte DOS pages and ``WINDOWS/`` (vendored in ``data/windows/``) for the Windows ANSI pages and the double-byte CJK pages. Single-byte pages become full 256-entry ``[Option; 256]`` tables: MS-DOS pages are not uniformly ASCII in the low half (CP864 maps 0x25 to ARABIC PERCENT SIGN) and several pages leave byte values undefined (CP857/864/869/874, most of the ANSI pages). Double-byte pages become a 256-entry single-byte/lead-byte table plus a pair of sorted ``(code, scalar)`` arrays for binary search. Where several codes decode to one scalar (CP932's NEC/IBM overlap, CP950's box-drawing rows), the encoder's choice is resolved from Microsoft's own ``bestfit*.txt`` WCTABLE — used *only* to pick among duplicate candidates, never to import lossy best-fit mappings. Run from the crate root: ``uv run tools/gen_tables.py`` (needs ``rustfmt`` on PATH). """ from __future__ import annotations import re import subprocess from collections.abc import Sequence from dataclasses import dataclass from pathlib import Path DOS_PAGES: Sequence[int] = ( 437, 737, 775, 850, 852, 855, 857, 860, 861, 862, 863, 864, 865, 866, 869, 874, ) ANSI_PAGES: Sequence[int] = ( 874, 1250, 1251, 1252, 1253, 1254, 1255, 1256, 1257, 1258, ) DOUBLE_BYTE_PAGES: Sequence[int] = (932, 936, 949, 950) DATA_DIR = Path(__file__).resolve().parent.parent / "data" PC_DIR = DATA_DIR / "pc" WINDOWS_DIR = DATA_DIR / "windows" BESTFIT_DIR = DATA_DIR / "bestfit" OUTPUT_DIR = Path(__file__).resolve().parent.parent / "src" / "tables" GENERATED_NOTE = """\ //! Generated by `tools/gen_tables.py` — do not edit by hand. //! //! Source: the Unicode Consortium's Microsoft mapping tables, vendored in //! `data/`. """ SURROGATE_RANGE = range(0xD800, 0xE000) class TableError(Exception): """A mapping table failed to parse or violated an invariant.""" @dataclass class CodePageTable: """One parsed Microsoft mapping table. Attributes: single: Byte value to Unicode scalar for single-byte codes. leads: Byte values marked ``DBCS LEAD BYTE`` (empty for single-byte pages). pairs: 16-bit lead<<8|trail code to Unicode scalar. listed_bytes: Every byte value the file listed, mapped or not. """ single: dict[int, int] leads: set[int] pairs: dict[int, int] listed_bytes: set[int] def read_mapping_lines(path: Path) -> list[str]: """Read a mapping file and return its data lines (those starting 0x). Raises: TableError: If the file is missing or non-ASCII (the lone stray byte in canonical data is a DOS EOF marker, 0x1A, which is still ASCII). """ try: text = path.read_text(encoding="ascii") except (OSError, UnicodeDecodeError) as error: raise TableError(f"{path.name}: unreadable mapping table") from error return [line.strip() for line in text.splitlines() if line.strip().startswith("0x")] def parse_table(path: Path) -> CodePageTable: """Parse one CP*.TXT into single-byte, lead-byte, and pair mappings. Raises: TableError: If a code is listed twice, a single byte is out of range, a scalar is a surrogate or beyond the BMP, or byte 0x00 appears as a DBCS trail (which would break NUL-terminated decoding). """ table = CodePageTable(single={}, leads=set(), pairs={}, listed_bytes=set()) seen: set[int] = set() for line in read_mapping_lines(path): columns = line.split("\t") code = int(columns[0], 16) if code in seen: raise TableError(f"{path.name}: code {code:#06x} listed twice") seen.add(code) if code > 0xFFFF: raise TableError(f"{path.name}: code {code:#x} out of range") if code <= 0xFF: table.listed_bytes.add(code) mapped = len(columns) >= 2 and columns[1].strip().startswith("0x") if not mapped: if code <= 0xFF and "DBCS LEAD BYTE" in line: table.leads.add(code) continue scalar = int(columns[1], 16) if scalar in SURROGATE_RANGE or scalar > 0xFFFF: raise TableError(f"{path.name}: scalar {scalar:#06x} not a BMP char") if code <= 0xFF: table.single[code] = scalar else: if code & 0xFF == 0: raise TableError(f"{path.name}: NUL trail byte in {code:#06x}") table.pairs[code] = scalar unmarked_leads = {code >> 8 for code in table.pairs} - table.leads if unmarked_leads: raise TableError( f"{path.name}: pairs under bytes not marked DBCS LEAD BYTE: " f"{[hex(lead) for lead in sorted(unmarked_leads)]}" ) return table def parse_wctable(path: Path) -> dict[int, int]: """Parse the WCTABLE (Unicode to codepage) section of a bestfit file. Raises: TableError: If the file is missing or has no WCTABLE section. """ try: # Comments embed raw codepage-encoded glyph bytes; latin-1 accepts # any byte and the data lines we match are pure ASCII. text = path.read_text(encoding="latin-1") except OSError as error: raise TableError(f"{path.name}: unreadable bestfit table") from error _, marker, rest = text.partition("WCTABLE") if not marker: raise TableError(f"{path.name}: no WCTABLE section") section = rest.split("ENDCODEPAGE")[0] entry = re.compile(r"^0x([0-9a-fA-F]{4})\s+0x([0-9a-fA-F]+)") table: dict[int, int] = {} for line in section.splitlines(): if match := entry.match(line.strip()): table[int(match.group(1), 16)] = int(match.group(2), 16) return table def check_bijection(path_name: str, table: CodePageTable) -> None: """Require every scalar to have exactly one code (single-byte pages). Raises: TableError: If two codes map to the same scalar. """ scalars = list(table.single.values()) + list(table.pairs.values()) if len(scalars) != len(set(scalars)): raise TableError(f"{path_name}: table is not a bijection") def resolve_encode( path_name: str, table: CodePageTable, wctable: dict[int, int] ) -> dict[int, int]: """Choose one code per scalar, resolving duplicates via the WCTABLE. Args: path_name: The mapping file name, for error messages. table: The parsed decode-direction table. wctable: Microsoft's Unicode-to-codepage table for this page. Returns: Scalar to pair-code for the double-byte portion only (single-byte codes never collide with pairs in these tables, which is asserted). Raises: TableError: If a duplicate set is not settled by the WCTABLE, mixes single-byte and pair codes (the Rust encoder tries the single table first, so a pair winner would be unreachable), or has a single-byte winner that is not the lowest byte (the Rust encoder scans upward, so any other winner would be shadowed). """ candidates: dict[int, list[int]] = {} for code, scalar in list(table.single.items()) + list(table.pairs.items()): candidates.setdefault(scalar, []).append(code) encode: dict[int, int] = {} for scalar, codes in candidates.items(): if len(codes) == 1: chosen = codes[0] else: if any(c <= 0xFF for c in codes) and any(c > 0xFF for c in codes): raise TableError( f"{path_name}: U+{scalar:04X} mixes single and pair codes " f"({[hex(c) for c in codes]})" ) chosen = wctable.get(scalar, -1) if chosen not in codes: raise TableError( f"{path_name}: WCTABLE does not settle duplicate scalar " f"U+{scalar:04X} (candidates {[hex(c) for c in codes]})" ) if chosen <= 0xFF and chosen != min(codes): raise TableError( f"{path_name}: U+{scalar:04X} single-byte winner " f"{chosen:#04x} is not the lowest candidate" ) if chosen <= 0xFF: continue # single-byte encoding is derived from the single table encode[scalar] = chosen return encode 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_single_byte_page(code_page: int, table: CodePageTable) -> str: """Render one single-byte code page as an ``[Option; 256]``.""" lines = [ f"/// Code page {code_page}, byte value to Unicode scalar.", f"pub(crate) const CP{code_page}: [Option; 256] = [", ] for row_start in range(0, 256, 8): entries = [] for byte in range(row_start, row_start + 8): scalar = table.single.get(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_pair_array(name: str, doc: str, pairs: Sequence[tuple[int, int]]) -> str: """Render a sorted ``[(u16, char); N]`` static, eight entries per line.""" lines = [f"/// {doc}", f"static {name}: [(u16, char); {len(pairs)}] = ["] for row_start in range(0, len(pairs), 8): row = pairs[row_start : row_start + 8] rendered = ", ".join(f"({code:#06x}, {rust_char(scalar)})" for code, scalar in row) lines.append(f" {rendered},") lines.append("];\n") return "\n".join(lines) def render_encode_array(name: str, doc: str, pairs: Sequence[tuple[int, int]]) -> str: """Render a sorted ``[(char, u16); N]`` static, eight entries per line.""" lines = [f"/// {doc}", f"static {name}: [(char, u16); {len(pairs)}] = ["] for row_start in range(0, len(pairs), 8): row = pairs[row_start : row_start + 8] rendered = ", ".join(f"({rust_char(scalar)}, {code:#06x})" for scalar, code in row) lines.append(f" {rendered},") lines.append("];\n") return "\n".join(lines) def render_double_byte_page( code_page: int, table: CodePageTable, encode: dict[int, int] ) -> str: """Render one DBCS page as a module body with single/decode/encode tables.""" single_lines = [ "/// Single-byte slots: mapped char, DBCS lead byte, or undefined.", "const SINGLE: [SingleEntry; 256] = [", ] for row_start in range(0, 256, 8): entries = [] for byte in range(row_start, row_start + 8): if byte in table.leads: entries.append("L") elif (scalar := table.single.get(byte)) is not None: entries.append(f"M({rust_char(scalar)})") else: entries.append("U") single_lines.append( f" {', '.join(entries)}, // {row_start:#04x}-{row_start + 7:#04x}" ) single_lines.append("];\n") decode_pairs = sorted(table.pairs.items()) encode_pairs = sorted(encode.items()) return "\n".join( [ GENERATED_NOTE, "use super::SingleEntry::{self, Lead as L, Map as M, Undefined as U};", "use super::DbcsPage;", "", f"/// Code page {code_page}.", f"pub(crate) static CP{code_page}: DbcsPage = DbcsPage {{", " single: &SINGLE,", " decode: &DECODE,", " encode: &ENCODE,", "};", "", "\n".join(single_lines), render_pair_array( "DECODE", "Every defined pair, sorted by 16-bit code (lead<<8 | trail).", decode_pairs, ), render_encode_array( "ENCODE", "One pair per scalar, sorted by scalar; duplicates resolved per " "Microsoft's bestfit WCTABLE.", encode_pairs, ), ] ) def generate_single_byte_module( output_name: str, source_dir: Path, code_pages: Sequence[int] ) -> Path: """Render a family of single-byte pages into one tables module. Raises: TableError: If any page skips a byte value or contains DBCS content. """ rendered = [GENERATED_NOTE] for code_page in code_pages: table = parse_table(source_dir / f"CP{code_page}.TXT") if table.listed_bytes != set(range(256)): missing = sorted(set(range(256)) - table.listed_bytes) raise TableError(f"CP{code_page}: bytes never listed: {missing}") if table.leads or table.pairs: raise TableError(f"CP{code_page}: unexpected DBCS content") check_bijection(f"CP{code_page}", table) rendered.append(render_single_byte_page(code_page, table)) path = OUTPUT_DIR / output_name path.write_text("\n".join(rendered), encoding="utf-8") return path def main() -> None: """Regenerate src/tables/ from every configured code page.""" outputs = [ generate_single_byte_module("dos.rs", PC_DIR, DOS_PAGES), generate_single_byte_module("ansi.rs", WINDOWS_DIR, ANSI_PAGES), ] for code_page in DOUBLE_BYTE_PAGES: table = parse_table(WINDOWS_DIR / f"CP{code_page}.TXT") wctable = parse_wctable(BESTFIT_DIR / f"bestfit{code_page}.txt") encode = resolve_encode(f"CP{code_page}", table, wctable) page_path = OUTPUT_DIR / f"cp{code_page}.rs" page_path.write_text( render_double_byte_page(code_page, table, encode), encoding="utf-8" ) outputs.append(page_path) subprocess.run( ["rustfmt", "--edition", "2021", *map(str, outputs)], check=True ) for path in outputs: print(f"wrote {path}") if __name__ == "__main__": main()