| #!/usr/bin/env python3 | |
| """Generates the UAX #14 line break class table for src/text/line_break_data.cpp. | |
| Usage: | |
| generate_line_break.py --download 17.0.0 | |
| generate_line_break.py --ucd <dir with LineBreak.txt, UnicodeData.txt, | |
| EastAsianWidth.txt, emoji-data.txt, | |
| LineBreakTest.txt> | |
| Writes src/text/line_break_data.cpp and the comment stripped conformance | |
| file tests/unit_tests/assets/unicode/LineBreakTest.txt. | |
| """ | |
| import argparse | |
| import os | |
| import re | |
| import shutil | |
| import subprocess | |
| import sys | |
| import urllib.request | |
| RUNTIME = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..")) | |
| DATA_OUT = os.path.join(RUNTIME, "src", "text", "line_break_data.cpp") | |
| TEST_OUT = os.path.join(RUNTIME, "tests", "unit_tests", "assets", "unicode", | |
| "LineBreakTest.txt") | |
| UCD_FILES = { | |
| "LineBreak.txt": "LineBreak.txt", | |
| "UnicodeData.txt": "UnicodeData.txt", | |
| "EastAsianWidth.txt": "EastAsianWidth.txt", | |
| "emoji-data.txt": "emoji/emoji-data.txt", | |
| "LineBreakTest.txt": "auxiliary/LineBreakTest.txt", | |
| } | |
| # Must match LineBreakClass in include/rive/text/line_break.hpp. AI, SG, XX | |
| # and SA are resolved by LB1 at generation time so they never appear. | |
| CLASSES = [ | |
| "AL", "AK", "AP", "AS", "B2", "BA", "BB", "BK", "CB", "CJ", "CL", "CM", | |
| "CP", "CR", "EB", "EM", "EX", "GL", "H2", "H3", "HH", "HL", "HY", "ID", | |
| "IN", "IS", "JL", "JT", "JV", "LF", "NL", "NS", "NU", "OP", "PO", "PR", | |
| "QU", "RI", "SP", "SY", "VF", "VI", "WJ", "ZW", "ZWJ", | |
| ] | |
| # Must match the LineBreakFlags bits in line_break.hpp. | |
| FLAG_EAST_ASIAN = 1 | |
| FLAG_PI = 2 | |
| FLAG_PF = 4 | |
| FLAG_DOTTED_CIRCLE = 8 | |
| FLAG_PICTOGRAPHIC_CN = 16 | |
| MAX_CP = 0x110000 | |
| def download(version, out_dir): | |
| os.makedirs(out_dir, exist_ok=True) | |
| for name, path in UCD_FILES.items(): | |
| dest = os.path.join(out_dir, name) | |
| if os.path.exists(dest): | |
| continue | |
| url = f"https://www.unicode.org/Public/{version}/ucd/{path}" | |
| print(f"fetching {url}") | |
| urllib.request.urlretrieve(url, dest) | |
| def parse_ranges(path, value_index=1): | |
| """Yields (start, end, value) for lines like 'XXXX..YYYY ; VALUE'.""" | |
| with open(path, encoding="utf-8") as f: | |
| for line in f: | |
| line = line.split("#", 1)[0].strip() | |
| if not line: | |
| continue | |
| fields = [x.strip() for x in line.split(";")] | |
| cps = fields[0] | |
| if ".." in cps: | |
| a, b = cps.split("..") | |
| else: | |
| a = b = cps | |
| yield int(a, 16), int(b, 16), fields[value_index] | |
| def fill(table, path, default, value_index=1, only=None): | |
| for a, b, v in parse_ranges(path, value_index): | |
| if only is not None and v != only: | |
| continue | |
| for cp in range(a, b + 1): | |
| table[cp] = v | |
| def parse_general_category(path): | |
| gc = ["Cn"] * MAX_CP | |
| first = None | |
| with open(path, encoding="utf-8") as f: | |
| for line in f: | |
| fields = line.rstrip("\n").split(";") | |
| cp = int(fields[0], 16) | |
| name = fields[1] | |
| cat = fields[2] | |
| if name.endswith(", First>"): | |
| first = cp | |
| continue | |
| if name.endswith(", Last>"): | |
| for c in range(first, cp + 1): | |
| gc[c] = cat | |
| first = None | |
| continue | |
| gc[cp] = cat | |
| return gc | |
| def build_props(ucd): | |
| lb = ["XX"] * MAX_CP | |
| fill(lb, os.path.join(ucd, "LineBreak.txt"), "XX") | |
| eaw = ["N"] * MAX_CP | |
| fill(eaw, os.path.join(ucd, "EastAsianWidth.txt"), "N") | |
| pict = [False] * MAX_CP | |
| for a, b, v in parse_ranges(os.path.join(ucd, "emoji-data.txt")): | |
| if v == "Extended_Pictographic": | |
| for cp in range(a, b + 1): | |
| pict[cp] = True | |
| gc = parse_general_category(os.path.join(ucd, "UnicodeData.txt")) | |
| props = [] | |
| for cp in range(MAX_CP): | |
| cls = lb[cp] | |
| cat = gc[cp] | |
| if cls in ("AI", "SG", "XX"): | |
| cls = "AL" | |
| elif cls == "SA": | |
| cls = "CM" if cat in ("Mn", "Mc") else "AL" | |
| flags = 0 | |
| if eaw[cp] in ("F", "W", "H"): | |
| flags |= FLAG_EAST_ASIAN | |
| if cat == "Pi": | |
| flags |= FLAG_PI | |
| if cat == "Pf": | |
| flags |= FLAG_PF | |
| if cp == 0x25CC: | |
| flags |= FLAG_DOTTED_CIRCLE | |
| if pict[cp] and cat == "Cn": | |
| flags |= FLAG_PICTOGRAPHIC_CN | |
| props.append((cls, flags)) | |
| return props | |
| def build_trie(values, default): | |
| """Picks the smallest three level layout: a top index by cp >> shift1, | |
| middle blocks indexing data blocks of 1 << shift2 values.""" | |
| high = MAX_CP | |
| while high > 0 and values[high - 1] == default: | |
| high -= 1 | |
| best = None | |
| for shift2 in (4, 5, 6, 7): | |
| for shift1 in range(shift2 + 3, 13): | |
| block = 1 << shift2 | |
| group = 1 << shift1 | |
| high_start = (high + group - 1) // group * group | |
| data_blocks = {} | |
| mid_blocks = {} | |
| top = [] | |
| for gstart in range(0, high_start, group): | |
| mid = [] | |
| for start in range(gstart, gstart + group, block): | |
| chunk = tuple(values[start:start + block]) | |
| if chunk not in data_blocks: | |
| data_blocks[chunk] = len(data_blocks) | |
| mid.append(data_blocks[chunk]) | |
| mid = tuple(mid) | |
| if mid not in mid_blocks: | |
| mid_blocks[mid] = len(mid_blocks) | |
| top.append(mid_blocks[mid]) | |
| top_width = 1 if len(mid_blocks) <= 256 else 2 | |
| mid_width = 1 if len(data_blocks) <= 256 else 2 | |
| size = (len(top) * top_width + | |
| len(mid_blocks) * (group >> shift2) * mid_width + | |
| len(data_blocks) * block) | |
| if best is None or size < best["size"]: | |
| best = { | |
| "shift1": shift1, | |
| "shift2": shift2, | |
| "high_start": high_start, | |
| "top": top, | |
| "top_width": top_width, | |
| "mid": [v for k in mid_blocks.keys() for v in k], | |
| "mid_width": mid_width, | |
| "data": [v for k in data_blocks.keys() for v in k], | |
| "size": size, | |
| "counts": (len(top), len(mid_blocks), len(data_blocks)), | |
| } | |
| return best | |
| def format_array(values, per_line=16): | |
| lines = [] | |
| for i in range(0, len(values), per_line): | |
| lines.append(" " + ", ".join(str(v) for v in values[i:i + per_line]) + ",") | |
| return "\n".join(lines) | |
| def write_data(props, version): | |
| ext = {} | |
| ext_list = [] | |
| values = [] | |
| for p in props: | |
| if p not in ext: | |
| ext[p] = len(ext_list) | |
| ext_list.append(p) | |
| values.append(ext[p]) | |
| assert len(ext_list) <= 256 | |
| default = ext[("AL", 0)] | |
| trie = build_trie(values, default) | |
| top_type = "uint8_t" if trie["top_width"] == 1 else "uint16_t" | |
| mid_type = "uint8_t" if trie["mid_width"] == 1 else "uint16_t" | |
| out = [] | |
| out.append("// Generated by dev/unicode/generate_line_break.py from Unicode " | |
| f"{version}. Do not edit.\n") | |
| out.append("#include \"rive/text/line_break.hpp\"\n\n") | |
| out.append("namespace rive\n{\n") | |
| out.append(f"extern const uint32_t kLineBreakHighStart = 0x{trie['high_start']:X};\n") | |
| out.append(f"extern const uint32_t kLineBreakTopShift = {trie['shift1']};\n") | |
| out.append(f"extern const uint32_t kLineBreakBlockShift = {trie['shift2']};\n") | |
| out.append(f"extern const uint8_t kLineBreakDefault = {default};\n\n") | |
| out.append(f"extern const LineBreakProps kLineBreakExtClasses[{len(ext_list)}] = {{\n") | |
| for cls, flags in ext_list: | |
| out.append(f" {{LineBreakClass::{cls}, {flags}}},\n") | |
| out.append("};\n\n") | |
| out.append(f"extern const {top_type} kLineBreakTop[{len(trie['top'])}] = {{\n") | |
| out.append(format_array(trie["top"])) | |
| out.append("\n};\n\n") | |
| out.append(f"extern const {mid_type} kLineBreakMid[{len(trie['mid'])}] = {{\n") | |
| out.append(format_array(trie["mid"])) | |
| out.append("\n};\n\n") | |
| out.append(f"extern const uint8_t kLineBreakData[{len(trie['data'])}] = {{\n") | |
| out.append(format_array(trie["data"], 24)) | |
| out.append("\n};\n") | |
| out.append("} // namespace rive\n") | |
| with open(DATA_OUT, "w", encoding="utf-8") as f: | |
| f.write("".join(out)) | |
| if shutil.which("clang-format"): | |
| subprocess.run(["clang-format", "-i", DATA_OUT], check=True) | |
| top, mid, data = trie["counts"] | |
| print(f"{len(ext_list)} extended classes, shifts {trie['shift1']}/" | |
| f"{trie['shift2']}, {top} top, {mid} mid, {data} data blocks, " | |
| f"high start 0x{trie['high_start']:X}, " | |
| f"{trie['size'] / 1024:.1f} KB of table data") | |
| def write_test(ucd): | |
| os.makedirs(os.path.dirname(TEST_OUT), exist_ok=True) | |
| count = 0 | |
| with open(os.path.join(ucd, "LineBreakTest.txt"), encoding="utf-8") as src, \ | |
| open(TEST_OUT, "w", encoding="utf-8") as dst: | |
| for line in src: | |
| if line.startswith("# LineBreakTest") or line.startswith("# Date"): | |
| dst.write(line) | |
| continue | |
| body = line.split("#", 1)[0].strip() | |
| if body: | |
| dst.write(body + "\n") | |
| count += 1 | |
| print(f"{count} conformance cases written to {TEST_OUT}") | |
| def main(): | |
| parser = argparse.ArgumentParser() | |
| parser.add_argument("--download", metavar="VERSION", | |
| help="fetch the UCD files for this Unicode version") | |
| parser.add_argument("--ucd", help="directory holding the UCD files") | |
| args = parser.parse_args() | |
| ucd = args.ucd | |
| version = None | |
| if args.download: | |
| version = args.download | |
| ucd = ucd or os.path.join(os.path.dirname(__file__), "ucd", version) | |
| download(version, ucd) | |
| if not ucd: | |
| parser.error("--download or --ucd required") | |
| with open(os.path.join(ucd, "LineBreak.txt"), encoding="utf-8") as f: | |
| m = re.match(r"# LineBreak-([\d.]+)\.txt", f.readline()) | |
| version = m.group(1) if m else version or "unknown" | |
| props = build_props(ucd) | |
| write_data(props, version) | |
| write_test(ucd) | |
| if __name__ == "__main__": | |
| sys.exit(main()) |