blob: 8ec3bc7ce9b073921bee3119eb00449b2f3a31b2 [file] [edit]
#!/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())