aboutsummaryrefslogtreecommitdiffstats
path: root/text/syllabic.py
diff options
context:
space:
mode:
Diffstat (limited to 'text/syllabic.py')
-rw-r--r--text/syllabic.py456
1 files changed, 456 insertions, 0 deletions
diff --git a/text/syllabic.py b/text/syllabic.py
new file mode 100644
index 0000000..6eb7c93
--- /dev/null
+++ b/text/syllabic.py
@@ -0,0 +1,456 @@
+#!/usr/bin/env python3
+"""The tables HarfBuzz's syllabic shapers read, as C (text/syllabic-data.c).
+
+ python3 text/syllabic.py HARFBUZZ_SRC > text/syllabic-data.c
+ python3 text/syllabic.py HARFBUZZ_SRC --check
+
+HARFBUZZ_SRC is HarfBuzz 10.2.0's src/ directory; what is read from it:
+
+- Each character's Indic category and position: hb-ot-shaper-indic-
+ table.cc, the table HarfBuzz's gen-indic-table.py made from Unicode 16's
+ IndicSyllabicCategory and IndicPositionalCategory with its own overrides,
+ read cell for cell (Unicode here is 15.1: the table is taken as HarfBuzz
+ has it). Khmer and Myanmar read the same table.
+
+- The syllable machines: hb-ot-shaper-*-machine.rl, the Ragel grammars.
+ This compiles each to a DFA with Ragel's scanner semantics (the longest
+ match from each position, ties to the pattern listed first), its symbols
+ folded into classes that behave alike. --check runs the DFAs beside the
+ machines Ragel generated from the same grammars (hb-ot-shaper-*-
+ machine.hh) on random runs of symbols, and counts where they differ.
+
+The output is deterministic: the same inputs give the same file.
+"""
+import random
+import re
+import sys
+from pathlib import Path
+
+HB = Path(sys.argv[1] if len(sys.argv) > 1 else '.')
+MACHINES = ['indic', 'khmer', 'myanmar']
+ALPHABET = 64 # categories past 63 are folded into 63, which only `any` takes
+
+
+# ---- Grammars: Ragel's regular expressions over symbols
+
+class Parser:
+ """Ragel's expressions as the machines use them: names, `a | b`,
+ `a . b` or `a b`, postfix `? * +`, parentheses."""
+
+ def __init__(self, text, names):
+ self.tokens = re.findall(r'[A-Za-z_][A-Za-z_0-9]*|[()|.?*+]', text)
+ self.at = 0
+ self.names = names
+
+ def peek(self):
+ return self.tokens[self.at] if self.at < len(self.tokens) else None
+
+ def take(self):
+ self.at += 1
+ return self.tokens[self.at - 1]
+
+ def alternation(self):
+ node = self.concatenation()
+ while self.peek() == '|':
+ self.take()
+ node = ('alt', node, self.concatenation())
+ return node
+
+ def concatenation(self):
+ node = self.postfix()
+ while self.peek() not in (None, '|', ')'):
+ if self.peek() == '.':
+ self.take()
+ node = ('cat', node, self.postfix())
+ return node
+
+ def postfix(self):
+ node = self.atom()
+ while self.peek() in ('?', '*', '+'):
+ node = ({'?': 'opt', '*': 'star', '+': 'plus'}[self.take()], node)
+ return node
+
+ def atom(self):
+ token = self.take()
+ if token == '(':
+ node = self.alternation()
+ assert self.take() == ')'
+ return node
+ return self.names[token]
+
+
+def parse(text, names):
+ parser = Parser(text, names)
+ node = parser.alternation()
+ assert parser.peek() is None, (text, parser.peek())
+ return node
+
+
+class Grammar:
+ """A machine's .rl: its symbols (exports), syllable types (the enum), and
+ the scanner's patterns in order, each with the type it finds."""
+
+ def __init__(self, name):
+ text = (HB / f'hb-ot-shaper-{name}-machine.rl').read_text()
+ enum = re.search(r'enum \w+_syllable_type_t \{(.*?)\};', text, re.S).group(1)
+ self.types = [t.strip() for t in enum.split(',') if t.strip()]
+ body = '\n'.join(block for block in re.findall(r'%%\{(.*?)\}%%', text, re.S) if 'main :=' in block)
+ body = re.sub(r'#[^\n]*', '', body)
+ main = re.search(r'main := \|\*(.*?)\*\|;', body, re.S).group(1)
+ rest = body.replace(re.search(r'main := \|\*.*?\*\|;', body, re.S).group(0), '')
+ self.symbols = {name: int(value) for name, value in re.findall(r'export (\w+)\s*=\s*(\d+);', rest)}
+ names = {name: ('sym', frozenset([value])) for name, value in self.symbols.items()}
+ names['any'] = ('any',)
+ for statement in rest.split(';'):
+ statement = statement.strip()
+ if not statement or statement.startswith('export'):
+ continue
+ name, expression = statement.split('=', 1)
+ names[name.strip()] = parse(expression, names)
+ self.patterns = []
+ for pattern, action in re.findall(r'([^;{}]+?)=>\s*\{(.*?)\};', main, re.S):
+ kind = re.search(r'found_syllable \((\w+)\)', action).group(1)
+ self.patterns.append((parse(pattern, names), self.types.index(kind)))
+ self.broken = next(i for i, t in enumerate(self.types) if t.endswith('broken_cluster'))
+
+
+class NFA:
+ def __init__(self):
+ self.edges = [] # state: [(symbols or None for epsilon, target)]
+ self.accept = {} # state: pattern index
+
+ def state(self):
+ self.edges.append([])
+ return len(self.edges) - 1
+
+ def build(self, node):
+ """Thompson's construction: (start, end) of node."""
+ kind = node[0]
+ if kind in ('sym', 'any'):
+ a, b = self.state(), self.state()
+ symbols = frozenset(range(ALPHABET)) if kind == 'any' else frozenset(min(s, ALPHABET - 1) for s in node[1])
+ self.edges[a].append((symbols, b))
+ return a, b
+ if kind == 'alt':
+ a, b = self.state(), self.state()
+ for part in node[1:]:
+ s, e = self.build(part)
+ self.edges[a].append((None, s))
+ self.edges[e].append((None, b))
+ return a, b
+ if kind == 'cat':
+ s1, e1 = self.build(node[1])
+ s2, e2 = self.build(node[2])
+ self.edges[e1].append((None, s2))
+ return s1, e2
+ s, e = self.build(node[1])
+ a, b = self.state(), self.state()
+ self.edges[a].append((None, s))
+ self.edges[e].append((None, b))
+ if kind in ('opt', 'star'):
+ self.edges[a].append((None, b))
+ if kind in ('star', 'plus'):
+ self.edges[e].append((None, s))
+ return a, b
+
+ def closure(self, states):
+ seen, stack = set(states), list(states)
+ while stack:
+ for symbols, target in self.edges[stack.pop()]:
+ if symbols is None and target not in seen:
+ seen.add(target)
+ stack.append(target)
+ return frozenset(seen)
+
+
+def machine(grammar):
+ """The scanner's DFA, minimal: each state's transitions by symbol (None
+ for none) and the syllable type it accepts (the first pattern's), or
+ None; the start is state 0."""
+ nfa = NFA()
+ start = nfa.state()
+ for index, (tree, kind) in enumerate(grammar.patterns):
+ s, e = nfa.build(tree)
+ nfa.edges[start].append((None, s))
+ nfa.accept[e] = index
+ first = nfa.closure([start])
+ states, order, transitions = {first: 0}, [first], []
+ for current in order:
+ row = []
+ for symbol in range(ALPHABET):
+ targets = [t for s in current for symbols, t in nfa.edges[s] if symbols is not None and symbol in symbols]
+ if not targets:
+ row.append(None)
+ continue
+ target = nfa.closure(targets)
+ if target not in states:
+ states[target] = len(order)
+ order.append(target)
+ row.append(states[target])
+ transitions.append(row)
+ accepts = []
+ for current in order:
+ matched = [nfa.accept[s] for s in current if s in nfa.accept]
+ accepts.append(grammar.patterns[min(matched)][1] if matched else None)
+ # Moore's partition refinement
+ n = len(transitions)
+ block = list(accepts)
+ while True:
+ signature = [(block[s],) + tuple(None if t is None else block[t] for t in transitions[s]) for s in range(n)]
+ numbering = {}
+ refined = [numbering.setdefault(signature[s], len(numbering)) for s in range(n)]
+ if len(numbering) == len(set(block)):
+ break
+ block = refined
+ new = {}
+ for s in range(n):
+ new.setdefault(block[s], len(new))
+ table, accept = [None] * len(new), [None] * len(new)
+ for s in range(n):
+ table[new[block[s]]] = [None if t is None else new[block[t]] for t in transitions[s]]
+ accept[new[block[s]]] = accepts[s]
+ # every symbol starts a syllable: the scanner never stalls
+ assert all(table[0][symbol] is not None and accept[table[0][symbol]] is not None for symbol in range(ALPHABET))
+ return table, accept
+
+
+def classes(table):
+ """Symbols whose columns are the same, folded."""
+ columns, of = {}, []
+ for symbol in range(ALPHABET):
+ of.append(columns.setdefault(tuple(row[symbol] for row in table), len(columns)))
+ return of, len(columns)
+
+
+def scan(table, accept, symbols):
+ """Syllables of a run of symbols by a DFA: (start, end, type)."""
+ found, p = [], 0
+ while p < len(symbols):
+ state, end, kind = 0, None, None
+ for q in range(p, len(symbols)):
+ state = table[state][min(symbols[q], ALPHABET - 1)]
+ if state is None:
+ break
+ if accept[state] is not None:
+ end, kind = q + 1, accept[state]
+ found.append((p, end, kind))
+ p = end
+ return found
+
+
+# ---- The Indic table
+
+def indic_table(grammars):
+ """Code point to (category, position) for the characters HarfBuzz's
+ table has, and the default for the others."""
+ text = (HB / 'hb-ot-shaper-indic-table.cc').read_text()
+ positions = {name: int(value) for name, value in
+ re.findall(r'(POS_\w+) = (\d+)', (HB / 'hb-ot-shaper-indic.hh').read_text())}
+ machines = {'I': grammars['indic'].symbols, 'K': grammars['khmer'].symbols, 'M': grammars['myanmar'].symbols}
+ long_names = {name: machines[m][cat] for name, m, cat in re.findall(r'#define (OT_\w+) ([IKM])_Cat\((\w+)\)', text)}
+ short = {name: long_names[target] for name, target in re.findall(r'#define (_OT_\w+)\s+(OT_\w+)', text)}
+ short.update({name: positions[target] for name, target in re.findall(r'#define (_POS_\w+)\s+(POS_\w+)', text)})
+
+ def cell(s, m):
+ return short['_OT_' + s], short['_POS_' + m]
+
+ cells = {}
+ table = text[text.index('static const uint16_t indic_table[]'):text.index('}; /* Table items:')]
+ for start, row in re.findall(r'/\* ([0-9A-F]{4,5}) \*/(.*)', table):
+ for k, (s, m) in enumerate(re.findall(r'_\((\w+),(\w+)\)', row)):
+ cells[int(start, 16) + k] = cell(s, m)
+ lookup = text[text.index('hb_indic_get_categories (hb_codepoint_t u)'):]
+ for code, s, m in re.findall(r'u == 0x([0-9A-F]+)u\)\) return _\((\w+),(\w+)\);', lookup):
+ cells[int(code, 16)] = cell(s, m)
+ default = cell(*re.search(r'return _\((\w+),(\w+)\);\s*\}', lookup).groups())
+ return cells, default
+
+
+def runs_of(cells):
+ """Runs of covered code points, gaps under 16 kept inside."""
+ runs = []
+ for code in sorted(cells):
+ if runs and code - runs[-1][1] <= 16:
+ runs[-1][1] = code
+ else:
+ runs.append([code, code])
+ return runs
+
+
+# ---- Checking against Ragel's machines (hb-ot-shaper-*-machine.hh)
+
+def ragel(name):
+ """A scanner from the flat tables and actions Ragel generated: symbols
+ to (start, end, type)."""
+ text = (HB / f'hb-ot-shaper-{name}-machine.hh').read_text()
+ grammar = Grammar(name)
+
+ def array(field):
+ body = re.search(rf'_{name}_syllable_machine_{field}\[\] = \{{(.*?)\}};', text, re.S).group(1)
+ return [int(v) for v in re.findall(r'-?\d+', body)]
+
+ keys, spans, offsets, indicies = array('trans_keys'), array('key_spans'), array('index_offsets'), array('indicies')
+ targets, actions = array('trans_targs'), array('trans_actions')
+ to_state, from_state, eof_trans = array('to_state_actions'), array('from_state_actions'), array('eof_trans')
+ start = int(re.search(rf'{name}_syllable_machine_start = (\d+);', text).group(1))
+
+ def cases(switch):
+ """The action cases of the switch on an array: number to code."""
+ at = text.index(f'switch ( _{name}_syllable_machine_{switch}[')
+ depth, begin = 0, text.index('{', at)
+ for k in range(begin, len(text)):
+ depth += {'{': 1, '}': -1}.get(text[k], 0)
+ if depth == 0:
+ body = text[begin + 1:k]
+ break
+ out, depth, current, k = {}, 0, None, 0
+ while k < len(body):
+ m = re.compile(r'case (\d+):').match(body, k) if depth == 0 else None
+ if m:
+ current = int(m.group(1))
+ out[current] = ''
+ k = m.end()
+ continue
+ depth += {'{': 1, '}': -1}.get(body[k], 0)
+ if current is not None:
+ out[current] += body[k]
+ k += 1
+ return out
+
+ trans_cases, to_cases, from_cases = cases('trans_actions'), cases('to_state_actions'), cases('from_state_actions')
+
+ def run(code, st):
+ # the `switch( act )` of a backtracking action: only the chosen case
+ m = re.search(r'switch\( act \) \{(.*)\}', code, re.S)
+ if m:
+ chosen = re.search(rf'case {st["act"]}:(.*?)break;', m.group(1), re.S)
+ code = code[:m.start()] + (chosen.group(1) if chosen else '')
+ for statement in re.findall(r'ts = p;|ts = 0;|te = p\+1;|te = p;|p--;|p = \(\(te\)\)-1;|act = \d+;|'
+ r'found_syllable \(\w+\)', code):
+ if statement == 'ts = p;': st['ts'] = st['p']
+ elif statement == 'ts = 0;': st['ts'] = 0
+ elif statement == 'te = p+1;': st['te'] = st['p'] + 1
+ elif statement == 'te = p;': st['te'] = st['p']
+ elif statement == 'p--;': st['p'] -= 1
+ elif statement == 'p = ((te))-1;': st['p'] = st['te'] - 1
+ elif statement.startswith('act'): st['act'] = int(statement[6:-1])
+ else:
+ kind = statement[len('found_syllable ('):-1]
+ st['found'].append((st['ts'], st['te'], grammar.types.index(kind)))
+
+ def scanner(symbols):
+ st = {'ts': 0, 'te': 0, 'act': 0, 'p': 0, 'found': []}
+ cs, pe = start, len(symbols)
+ state = 'resume' if pe else 'test_eof'
+ while True:
+ if state == 'resume':
+ if from_state[cs]:
+ run(from_cases[from_state[cs]], st)
+ c, low, high, span = symbols[st['p']], keys[2 * cs], keys[2 * cs + 1], spans[cs]
+ trans = indicies[offsets[cs] + (c - low if span > 0 and low <= c <= high else span)]
+ state = 'eof_trans'
+ if state == 'eof_trans':
+ cs = targets[trans]
+ if actions[trans]:
+ run(trans_cases[actions[trans]], st)
+ if to_state[cs]:
+ run(to_cases[to_state[cs]], st)
+ st['p'] += 1
+ state = 'resume' if st['p'] != pe else 'test_eof'
+ continue
+ if state == 'test_eof':
+ if st['p'] == pe and eof_trans[cs] > 0:
+ trans = eof_trans[cs] - 1
+ state = 'eof_trans'
+ continue
+ return st['found']
+ return scanner
+
+
+def check(runs=100000):
+ rng = random.Random(1)
+ for name in MACHINES:
+ grammar = Grammar(name)
+ table, accept = machine(grammar)
+ theirs = ragel(name)
+ symbols = sorted(set(grammar.symbols.values())) + [0, 19, 63]
+ differ = 0
+ for _ in range(runs):
+ run = [rng.choice(symbols) for _ in range(rng.randint(1, 14))]
+ a, b = scan(table, accept, run), theirs(run)
+ if a != b:
+ differ += 1
+ if differ <= 3:
+ print(name, 'differ:', run, a, b, file=sys.stderr)
+ print(f'{name}: {len(table)} states, {runs} runs, {differ} differ')
+
+
+# ---- The C file
+
+def emit_machine(out, name, grammar):
+ table, accept = machine(grammar)
+ of, count = classes(table)
+ dead = len(table)
+ column_of = {}
+ for symbol in range(ALPHABET):
+ column_of.setdefault(of[symbol], symbol)
+ width = 'uint8_t' if dead < 256 else 'uint16_t'
+ out.append(f'/* {name} (hb-ot-shaper-{name}-machine.rl): {len(table)} states, {count} classes of symbol;'
+ f' types {", ".join(grammar.types)}. */')
+ out.append(f'static const uint8_t {name}_class[{ALPHABET}] = {{')
+ for k in range(0, ALPHABET, 32):
+ out.append(' ' + ', '.join(str(c) for c in of[k:k + 32]) + ',')
+ out.append('};')
+ out.append(f'static const {width} {name}_next[{len(table)}][{count}] = {{')
+ for row in table:
+ out.append(' {' + ', '.join(str(dead if row[column_of[k]] is None else row[column_of[k]])
+ for k in range(count)) + '},')
+ out.append('};')
+ out.append(f'static const uint8_t {name}_accept[{len(table)}] = {{')
+ cells = [str(0 if a is None else a + 1) for a in accept]
+ for k in range(0, len(cells), 32):
+ out.append(' ' + ', '.join(cells[k:k + 32]) + ',')
+ out.append('};')
+ out.append(f'const VegaMachine vega_{name}_machine = {{{name}_class, {count}, {len(table)}, {name}_next[0], '
+ f'sizeof({name}_next[0][0]), {name}_accept, {grammar.broken + 1}}};')
+ out.append('')
+
+
+def main():
+ if len(sys.argv) > 2 and sys.argv[2] == '--check':
+ check()
+ return
+ grammars = {name: Grammar(name) for name in MACHINES}
+ cells, default = indic_table(grammars)
+ flat, starts = [], []
+ for a, b in runs_of(cells):
+ starts.append((a, b, len(flat)))
+ flat.extend(cells.get(code, default) for code in range(a, b + 1))
+ out = ['/* Generated by text/syllabic.py from HarfBuzz 10.2.0\'s sources; do not edit. */',
+ '#include "syllabic.h"', '',
+ '/* Each character\'s Indic category and position (hb-ot-shaper-indic-table.cc, Unicode 16):',
+ ' * category | position << 8. */',
+ f'static const uint16_t indic_cells[{len(flat)}] = {{']
+ for k in range(0, len(flat), 12):
+ out.append(' ' + ', '.join(f'0x{c | p << 8:04X}' for c, p in flat[k:k + 12]) + ',')
+ out.append('};')
+ out.append(f'static const struct {{ uint32_t first, last, at; }} indic_runs[{len(starts)}] = {{')
+ for a, b, at in starts:
+ out.append(f' {{0x{a:04X}, 0x{b:04X}, {at}}},')
+ out.append('};')
+ out.append('')
+ out.append('uint16_t vega_indic_categories(uint32_t u) {')
+ out.append(f' for (unsigned i = 0; i < {len(starts)} && u >= indic_runs[i].first; ++i)')
+ out.append(' if (u <= indic_runs[i].last) return indic_cells[indic_runs[i].at + u - indic_runs[i].first];')
+ out.append(f' return 0x{default[0] | default[1] << 8:04X};')
+ out.append('}')
+ out.append('')
+ out.append('/* The syllable machines: a DFA over classes of symbol; a state\'s accept is')
+ out.append(' * the syllable type it ends, plus one. */')
+ for name in MACHINES:
+ emit_machine(out, name, grammars[name])
+ sys.stdout.write('\n'.join(out) + '\n')
+
+
+if __name__ == '__main__':
+ main()