Skip to main content

max / quasi-type

7.0 KB · 210 lines History Blame Raw
1 #!/usr/bin/env python3
2 """Derive src/cells/table.rs from Unicode's own character names.
3
4 The Box Drawing and Block Elements blocks are named systematically, so the 160
5 recipes are already written down: BOX DRAWINGS DOWN LIGHT AND RIGHT HEAVY says
6 which arms a glyph has and how heavy each one is, and LEFT THREE EIGHTHS BLOCK
7 says which fraction of the cell is filled. Typing that out by hand would be 160
8 chances to transpose two arms in a way nothing catches until a border looks
9 wrong in one corner.
10
11 So the table is derived rather than authored, from the `unicodedata` module in
12 the Python standard library, and the result is committed. Re-run it when the
13 shape vocabulary changes:
14
15 python3 scripts/derive-cell-table.py > src/cells/table.rs
16
17 Every name must parse. The script fails rather than emitting a partial table,
18 because a name it did not understand is a glyph that would silently come out
19 blank.
20 """
21
22 import sys
23 import unicodedata as ud
24
25 WEIGHT = {"LIGHT": "L", "HEAVY": "H", "DOUBLE": "D", "SINGLE": "L"}
26 DIRS = {
27 "UP": ["up"],
28 "DOWN": ["down"],
29 "LEFT": ["left"],
30 "RIGHT": ["right"],
31 "HORIZONTAL": ["left", "right"],
32 "VERTICAL": ["up", "down"],
33 }
34 ARMS = ["left", "right", "up", "down"]
35 FRACTION = {
36 "ONE": 1, "TWO": 2, "THREE": 3, "FOUR": 4,
37 "FIVE": 5, "SIX": 6, "SEVEN": 7, "EIGHT": 8,
38 }
39
40
41 def die(msg):
42 print(f"derive-cell-table: {msg}", file=sys.stderr)
43 sys.exit(1)
44
45
46 def parse_box(cp):
47 """(kind, dash, arms) for a Box Drawing codepoint."""
48 name = ud.name(chr(cp))
49 body = name.replace("BOX DRAWINGS ", "")
50
51 if "DIAGONAL" in body:
52 if "UPPER RIGHT TO LOWER LEFT" in body:
53 return ("diagonal", "Rising", {})
54 if "UPPER LEFT TO LOWER RIGHT" in body:
55 return ("diagonal", "Falling", {})
56 if "CROSS" in body:
57 return ("diagonal", "Cross", {})
58 die(f"U+{cp:04X} {name}: unrecognised diagonal")
59
60 kind = "stems"
61 if "ARC " in body:
62 kind = "arc"
63 body = body.replace("ARC ", "")
64
65 dash = "None"
66 for spelling, variant in [
67 ("QUADRUPLE DASH", "Quadruple"),
68 ("TRIPLE DASH", "Triple"),
69 ("DOUBLE DASH", "Double"),
70 ]:
71 if spelling in body:
72 dash = variant
73 body = body.replace(spelling + " ", "")
74
75 arms = {}
76 for clause in body.split(" AND "):
77 tokens = clause.split()
78 weights = [t for t in tokens if t in WEIGHT]
79 directions = [t for t in tokens if t in DIRS]
80 leftover = [t for t in tokens if t not in WEIGHT and t not in DIRS]
81 if leftover or not directions:
82 die(f"U+{cp:04X} {name}: cannot read clause {clause!r}")
83 weight = WEIGHT[weights[0]] if weights else None
84 for direction in directions:
85 for arm in DIRS[direction]:
86 arms[arm] = weight
87
88 # A leading weight applies to every clause that named none of its own:
89 # "LIGHT DOWN AND RIGHT" weighs both arms, "DOWN LIGHT AND RIGHT HEAVY"
90 # weighs them separately.
91 stated = [w for w in arms.values() if w]
92 if not stated:
93 die(f"U+{cp:04X} {name}: no weight anywhere")
94 for arm, weight in arms.items():
95 if weight is None:
96 arms[arm] = stated[0]
97 return (kind, dash, arms)
98
99
100 def parse_block(cp):
101 """(kind, payload) for a Block Elements codepoint."""
102 name = ud.name(chr(cp))
103 if name == "FULL BLOCK":
104 return ("fill", [(0, 0, 8, 8)])
105 if name.endswith("SHADE"):
106 return ("shade", name.split()[0].capitalize())
107 if name.startswith("QUADRANT"):
108 quads = {
109 "UPPER LEFT": (0, 4, 4, 8),
110 "UPPER RIGHT": (4, 4, 8, 8),
111 "LOWER LEFT": (0, 0, 4, 4),
112 "LOWER RIGHT": (4, 0, 8, 4),
113 }
114 rects = []
115 for part in name[len("QUADRANT "):].split(" AND "):
116 if part not in quads:
117 die(f"U+{cp:04X} {name}: unknown quadrant {part!r}")
118 rects.append(quads[part])
119 return ("fill", rects)
120
121 tokens = name.split()
122 side, count, unit = tokens[0], tokens[1], tokens[2]
123 if unit.startswith("QUARTER"):
124 eighths = FRACTION[count] * 2
125 elif unit.startswith("EIGHTH"):
126 eighths = FRACTION[count]
127 elif count == "HALF":
128 eighths = 4
129 unit = "HALF"
130 else:
131 die(f"U+{cp:04X} {name}: unknown fraction {count} {unit}")
132 if count == "HALF":
133 eighths = 4
134
135 if side == "UPPER":
136 return ("fill", [(0, 8 - eighths, 8, 8)])
137 if side == "LOWER":
138 return ("fill", [(0, 0, 8, eighths)])
139 if side == "LEFT":
140 return ("fill", [(0, 0, eighths, 8)])
141 if side == "RIGHT":
142 return ("fill", [(8 - eighths, 0, 8, 8)])
143 die(f"U+{cp:04X} {name}: unknown side {side!r}")
144
145
146 def main():
147 stems, arcs, diagonals, fills, shades = [], [], [], [], []
148
149 for cp in range(0x2500, 0x2580):
150 kind, payload, arms = parse_box(cp)
151 if kind == "diagonal":
152 diagonals.append((cp, payload))
153 continue
154 row = (cp, [arms.get(a, "N") for a in ARMS], payload)
155 (arcs if kind == "arc" else stems).append(row)
156
157 for cp in range(0x2580, 0x25A0):
158 kind, payload = parse_block(cp)
159 (shades if kind == "shade" else fills).append((cp, payload))
160
161 counts = (len(stems), len(arcs), len(diagonals), len(fills), len(shades))
162 if sum(counts) != 160:
163 die(f"expected 160 codepoints, derived {sum(counts)}")
164
165 out = []
166 w = out.append
167 w("// Generated by scripts/derive-cell-table.py from Unicode character names.")
168 w("// Do not edit by hand: re-run the script instead. See its header for why")
169 w("// the table is derived rather than authored.")
170 w("//")
171 w(f"// {counts[0]} stems, {counts[1]} arcs, {counts[2]} diagonals,"
172 f" {counts[3]} fills, {counts[4]} shades.")
173 w("")
174 w("use super::Arm::{D, H, L, N};")
175 w("use super::{Dash, Diagonal, Shade};")
176 w("")
177 w("/// Arms in the order left, right, up, down.")
178 w(f"pub(super) const STEMS: [(u32, [super::Arm; 4], Dash); {counts[0]}] = [")
179 for cp, arms, dash in stems:
180 w(f" (0x{cp:04X}, [{', '.join(arms)}], Dash::{dash}),")
181 w("];")
182 w("")
183 w("/// The rounded corners. Same arms as a stem, drawn as a quarter turn.")
184 w(f"pub(super) const ARCS: [(u32, [super::Arm; 4]); {counts[1]}] = [")
185 for cp, arms, _ in arcs:
186 w(f" (0x{cp:04X}, [{', '.join(arms)}]),")
187 w("];")
188 w("")
189 w(f"pub(super) const DIAGONALS: [(u32, Diagonal); {counts[2]}] = [")
190 for cp, kind in diagonals:
191 w(f" (0x{cp:04X}, Diagonal::{kind}),")
192 w("];")
193 w("")
194 w("/// Filled rectangles in eighths of the cell: x0, y0, x1, y1, with y up.")
195 w(f"pub(super) const FILLS: [(u32, &[(u8, u8, u8, u8)]); {counts[3]}] = [")
196 for cp, rects in fills:
197 body = ", ".join(f"({a}, {b}, {c}, {d})" for a, b, c, d in rects)
198 w(f" (0x{cp:04X}, &[{body}]),")
199 w("];")
200 w("")
201 w(f"pub(super) const SHADES: [(u32, Shade); {counts[4]}] = [")
202 for cp, level in shades:
203 w(f" (0x{cp:04X}, Shade::{level}),")
204 w("];")
205 print("\n".join(out))
206
207
208 if __name__ == "__main__":
209 main()
210