blob: 4868c80b373c38569eca93ceb281836e1ee09162 [file]
#!/usr/bin/env python3
"""Generates the word lists that break lines inside scripts written without
spaces (UAX #14 class SA) into src/text/line_break_dict_<language>.cpp.
Usage:
generate_line_break_dictionaries.py --download release-78.1
generate_line_break_dictionaries.py --icu <dir with thaidict.txt,
laodict.txt, khmerdict.txt,
burmesedict.txt>
Each language compiles in only with its WITH_RIVE_TEXT_DICT_<LANGUAGE>
define, set by the with_rive_text_dict_<language> premake option. The word
lists are ICU's (data/brkitr/dictionaries), stored as a minimized trie, or
with WITH_RIVE_TEXT_DICT_COMPRESSED as the deflated word list the runtime
rebuilds that trie from on first use.
"""
import argparse
import os
import shutil
import subprocess
import sys
import urllib.request
import zlib
RUNTIME = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
SRC_DIR = os.path.join(RUNTIME, "src", "text")
# ranges are the codepoints LineBreak.txt assigns SA in each script, so
# digits and punctuation keep their own classes. no_break_before and
# no_break_after keep a syllable whole when a word isn't in the list: vowels
# written before their consonant, vowels and marks that follow it without
# being combining marks, and the signs that stack the next consonant.
# suffixes (repetition and abbreviation signs) can't start a word either,
# they belong to the word before them. coda_marks make the consonant they directly follow end the previous
# syllable (killers, asat, virama), so no break may precede that consonant.
# Only coda_skip marks may sit between them. Thai and Lao silence a consonant
# together with its above or below vowel (the i in di + THANTHAKHAT), while
# a Burmese vowel sign makes asat part of the vowel instead, like aw.
LANGUAGES = {
"thai": {
"file": "thaidict.txt",
"ranges": [(0x0E01, 0x0E3A), (0x0E40, 0x0E4E)],
"no_break_before": [0x0E30, 0x0E32, 0x0E33, 0x0E45],
"no_break_after": [0x0E40, 0x0E41, 0x0E42, 0x0E43, 0x0E44],
"suffixes": [0x0E2F, 0x0E46],
"coda_marks": [0x0E4C],
# Above and below vowels.
"coda_skip": [0x0E31, 0x0E34, 0x0E35, 0x0E36, 0x0E37, 0x0E38, 0x0E39,
0x0E3A, 0x0E47],
},
"lao": {
"file": "laodict.txt",
"ranges": [(0x0E81, 0x0ECE), (0x0EDC, 0x0EDF)],
"no_break_before": [0x0EB0, 0x0EB2, 0x0EB3],
"no_break_after": [0x0EC0, 0x0EC1, 0x0EC2, 0x0EC3, 0x0EC4],
"suffixes": [0x0EAF, 0x0EC6],
"coda_marks": [0x0ECC],
# Above and below vowels.
"coda_skip": [0x0EB1, 0x0EB4, 0x0EB5, 0x0EB6, 0x0EB7, 0x0EB8, 0x0EB9,
0x0EBA, 0x0EBB],
},
"khmer": {
"file": "khmerdict.txt",
"ranges": [(0x1780, 0x17D3), (0x17D7, 0x17D7), (0x17DC, 0x17DD)],
"no_break_before": [],
"no_break_after": [0x17D2],
"suffixes": [0x17D7],
# Not viriam: ICU's list keeps consonant + viriam as words of their own.
"coda_marks": [0x17CB, 0x17CD],
"coda_skip": [],
},
"burmese": {
"file": "burmesedict.txt",
"ranges": [(0x1000, 0x103F), (0x1050, 0x108F), (0x109A, 0x109F),
(0xA9E0, 0xA9EF), (0xA9FA, 0xA9FE), (0xAA60, 0xAA7F)],
"no_break_before": [],
"no_break_after": [0x1039],
"suffixes": [],
"coda_marks": [0x1039, 0x103A],
# The dot below sorts before asat in canonical order.
"coda_skip": [0x1037],
},
}
# Edge layout, must match line_break_dictionary.hpp.
SYMBOL_BITS = 7
LAST_EDGE = 1 << 7
WORD_END = 1 << 8
TARGET_SHIFT = 9
def download(tag, out_dir):
os.makedirs(out_dir, exist_ok=True)
for lang in LANGUAGES.values():
dest = os.path.join(out_dir, lang["file"])
if os.path.exists(dest):
continue
url = ("https://raw.githubusercontent.com/unicode-org/icu/"
f"{tag}/icu4c/source/data/brkitr/dictionaries/{lang['file']}")
print(f"fetching {url}")
urllib.request.urlretrieve(url, dest)
def read_words(path):
"""Returns the leading license comment and the sorted word list."""
header = []
words = set()
with open(path, encoding="utf-8-sig") as f:
for line in f:
line = line.strip()
if line.startswith("#"):
# Later comments are commented out words.
if not words:
header.append(" ".join(line.lstrip("#").split()))
continue
# Some lists carry a tab separated value after the word.
word = line.split()[0] if line else ""
if word:
words.add(word)
return [h for h in header if h], sorted(words)
def build_edges(words, alphabet):
"""Builds a minimized trie as a flat edge list. Each state's edges are
contiguous and sorted by symbol, the root's start at 0, and an edge
points at its target's first edge (0 when the target has none)."""
symbol = {c: i for i, c in enumerate(alphabet)}
trie = {}
for word in words:
node = trie
for i, c in enumerate(word):
child = node.setdefault(symbol[c], [False, {}])
if i == len(word) - 1:
child[0] = True
node = child[1]
# Merge identical subtrees. A state is its sorted (symbol, word end,
# target) edges, so the empty state is the shared leaf.
states = {}
order = []
def intern(node):
key = tuple(sorted((s, end, intern(child))
for s, (end, child) in node.items()))
if key not in states:
states[key] = len(order)
order.append(key)
return states[key]
root = intern(trie)
# Lay states out breadth first from the root so it lands at edge 0.
first_edge = {}
layout = []
queue = [root]
seen = {root}
edge_count = 0
while queue:
state = queue.pop(0)
edges = order[state]
if not edges:
continue
first_edge[state] = edge_count
layout.append(state)
edge_count += len(edges)
for _, _, target in edges:
if target not in seen:
seen.add(target)
queue.append(target)
out = []
for state in layout:
edges = order[state]
for i, (s, end, target) in enumerate(edges):
value = s | (first_edge.get(target, 0) << TARGET_SHIFT)
if end:
value |= WORD_END
if i == len(edges) - 1:
value |= LAST_EDGE
out.append(value)
assert first_edge[root] == 0
assert edge_count < (1 << (32 - TARGET_SHIFT))
return out
def check(words, alphabet, edges):
"""Walks every word back out of the edge list."""
symbol = {c: i for i, c in enumerate(alphabet)}
for word in words:
state = 0
end = False
for i, c in enumerate(word):
if i > 0 and state == 0:
raise AssertionError(f"{word} runs past a leaf")
s = symbol[c]
e = state
while True:
if edges[e] & ((1 << SYMBOL_BITS) - 1) == s:
break
assert not edges[e] & LAST_EDGE, f"{word} missing"
e += 1
end = bool(edges[e] & WORD_END)
state = edges[e] >> TARGET_SHIFT
assert end, f"{word} not marked as a word"
def checksum(edges):
"""FNV-1a over the edge values, must match line_break_dictionary.cpp."""
h = 2166136261
for e in edges:
h = ((h ^ e) * 16777619) & 0xFFFFFFFF
return h
def compress_words(words, alphabet):
"""Front codes the sorted words, each as the length it shares with the
previous word, its remaining symbols plus one, and a 0, then deflates
them (raw RFC 1951). The runtime rebuilds the same trie from them."""
symbol = {c: i for i, c in enumerate(alphabet)}
coded = bytearray()
prev = ""
for word in words:
shared = 0
while (shared < min(len(word), len(prev)) and
word[shared] == prev[shared]):
shared += 1
coded.append(shared)
coded += bytes(symbol[c] + 1 for c in word[shared:])
coded.append(0)
prev = word
deflate = zlib.compressobj(9, zlib.DEFLATED, -15, 9)
return deflate.compress(bytes(coded)) + deflate.flush(), len(coded)
def format_array(values, per_line):
lines = []
for i in range(0, len(values), per_line):
lines.append(" " + ", ".join(values[i:i + per_line]) + ",")
return "\n".join(lines)
def hex_list(values):
return [f"0x{v:04X}" for v in values]
def write_language(name, lang, header, words, tag):
alphabet = sorted({c for w in words for c in w})
assert len(alphabet) <= 1 << SYMBOL_BITS
edges = build_edges(words, alphabet)
check(words, alphabet, edges)
title = name.capitalize()
define = f"WITH_RIVE_TEXT_DICT_{name.upper()}"
prefix = f"k{title}"
ranges = [v for r in lang["ranges"] for v in r]
def span(array, values):
return f"Span<const uint32_t>({array}, {len(values)})" \
if values else "Span<const uint32_t>()"
out = []
out.append("// Generated by dev/unicode/generate_line_break_dictionaries.py "
f"from ICU {tag}\n// {lang['file']}. Do not edit.\n//\n")
for line in header:
out.append(f"// {line}\n")
out.append("\n#include \"rive/text/line_break_dictionary.hpp\"\n\n")
out.append(f"#ifdef {define}\n\n")
out.append("namespace rive\n{\n")
out.append("// clang-format off\n")
out.append(f"static const uint32_t {prefix}Ranges[{len(ranges)}] = {{\n")
out.append(format_array(hex_list(ranges), 8))
out.append("\n};\n\n")
out.append(f"static const uint32_t {prefix}Alphabet[{len(alphabet)}] = {{\n")
out.append(format_array(hex_list(ord(c) for c in alphabet), 8))
out.append("\n};\n\n")
for key, array in (("no_break_before", "NoBreakBefore"),
("no_break_after", "NoBreakAfter"),
("suffixes", "Suffixes"),
("coda_marks", "CodaMarks"),
("coda_skip", "CodaSkip")):
if lang[key]:
out.append(f"static const uint32_t {prefix}{array}"
f"[{len(lang[key])}] = {{\n")
out.append(format_array(hex_list(lang[key]), 8))
out.append("\n};\n\n")
compressed, coded_size = compress_words(words, alphabet)
out.append("#ifdef WITH_RIVE_TEXT_DICT_COMPRESSED\n")
out.append(f"static const uint8_t {prefix}Words[{len(compressed)}] = {{\n")
out.append(format_array([str(b) for b in compressed], 16))
out.append("\n};\n")
out.append("#else\n")
out.append(f"static const uint32_t {prefix}Edges[{len(edges)}] = {{\n")
out.append(format_array([str(e) for e in edges], 10))
out.append("\n};\n")
out.append("#endif\n")
out.append("// clang-format on\n\n")
out.append(f"extern const LineBreakDictionary kLineBreakDictionary{title} = {{\n")
out.append(f" {span(prefix + 'Ranges', ranges)},\n")
out.append(f" {span(prefix + 'Alphabet', alphabet)},\n")
out.append(f" {span(prefix + 'NoBreakBefore', lang['no_break_before'])},\n")
out.append(f" {span(prefix + 'NoBreakAfter', lang['no_break_after'])},\n")
out.append(f" {span(prefix + 'Suffixes', lang['suffixes'])},\n")
out.append(f" {span(prefix + 'CodaMarks', lang['coda_marks'])},\n")
out.append(f" {span(prefix + 'CodaSkip', lang['coda_skip'])},\n")
out.append("#ifdef WITH_RIVE_TEXT_DICT_COMPRESSED\n")
out.append(f" Span<const uint8_t>({prefix}Words, {len(compressed)}),\n")
out.append(f" {coded_size},\n")
out.append("#else\n")
out.append(f" {span(prefix + 'Edges', edges)},\n")
out.append("#endif\n")
out.append(f" {len(edges)},\n")
out.append(f" 0x{checksum(edges):08X},\n")
out.append("};\n")
out.append("} // namespace rive\n\n")
out.append(f"#endif\n")
path = os.path.join(SRC_DIR, f"line_break_dict_{name}.cpp")
with open(path, "w", encoding="utf-8") as f:
f.write("".join(out))
if shutil.which("clang-format"):
subprocess.run(["clang-format", "-i", path], check=True)
print(f"{name}: {len(words)} words, {len(alphabet)} symbols, "
f"{len(edges)} edges, {len(edges) * 4 / 1024:.1f} KB, "
f"{len(compressed) / 1024:.1f} KB compressed")
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--download", metavar="TAG",
help="fetch the dictionaries from this ICU release "
"tag, e.g. release-78.1")
parser.add_argument("--icu", help="directory holding the dictionaries")
args = parser.parse_args()
icu = args.icu
tag = "unknown"
if args.download:
tag = args.download
icu = icu or os.path.join(os.path.dirname(__file__), "icu", tag)
download(tag, icu)
if not icu:
parser.error("--download or --icu required")
tag = tag.removeprefix("release-")
for name, lang in LANGUAGES.items():
header, words = read_words(os.path.join(icu, lang["file"]))
write_language(name, lang, header, words, tag)
if __name__ == "__main__":
sys.exit(main())