nitai/projects
noxstrap / src / crates / nox-text / tests / gen_fixture.py
297 lines · 9.3 KB Raw
1#!/usr/bin/env python3
2"""Independent TrueType reader used only to cross-check nox-text.
3
4Deliberately written from the spec with nothing but `struct`, so that agreement
5with the Rust parser is real evidence and not a shared bug. Emits a JSON fixture
6consumed by tests/parser.rs.
7"""
8import json
9import struct
10import sys
11
12U16 = lambda b, o: struct.unpack_from(">H", b, o)[0]
13S16 = lambda b, o: struct.unpack_from(">h", b, o)[0]
14U32 = lambda b, o: struct.unpack_from(">I", b, o)[0]
15
16PROBE = ("A", "V", "T", "W", "L", "M", "Q", "a", "o", "g", "f", "i", "z", "1", " ", ".",
17 ",", "@", "~", "\u00e9", "\u00fc", "\u00f1", "\u03a9", "\u2192", "\u5b57")
18
19
20def tables(b):
21 off = 0
22 if b[:4] == b"ttcf":
23 off = U32(b, 12)
24 n = U16(b, off + 4)
25 out = {}
26 for i in range(n):
27 e = off + 12 + i * 16
28 tag = b[e:e + 4].decode("latin1")
29 out[tag] = (U32(b, e + 8), U32(b, e + 12))
30 return out
31
32
33def cmap_lookup(b, base, ch):
34 """Walk every subtable, score them, then resolve through the winner."""
35 n = U16(b, base + 2)
36 best, best_score = None, -1
37 for i in range(n):
38 rec = base + 4 + i * 8
39 plat, enc, off = U16(b, rec), U16(b, rec + 2), U32(b, rec + 4)
40 sub = base + off
41 if sub + 4 > len(b):
42 continue
43 fmt = U16(b, sub)
44 score = {(3, 10, 12): 100, (0, 3, 12): 95, (0, 4, 12): 95, (0, 6, 12): 95,
45 (3, 1, 4): 90, (0, 3, 4): 85, (0, 4, 4): 85, (0, 6, 4): 85,
46 (0, 0, 4): 85, (0, 1, 4): 85, (0, 2, 4): 85,
47 (3, 0, 4): 70, (1, 0, 6): 40, (1, 0, 0): 30}.get((plat, enc, fmt))
48 if score is None:
49 score = 20 if fmt in (0, 4, 6, 12) else None
50 if score is None:
51 continue
52 if score > best_score:
53 best, best_score = (sub, fmt), score
54 if best is None:
55 return None
56 sub, fmt = best
57 cp = ord(ch)
58 if fmt == 0:
59 return b[sub + 6 + cp] if cp < 256 else 0
60 if fmt == 6:
61 first, count = U16(b, sub + 6), U16(b, sub + 8)
62 if cp < first or cp >= first + count:
63 return 0
64 return U16(b, sub + 10 + (cp - first) * 2)
65 if fmt == 4:
66 if cp > 0xFFFF:
67 return 0
68 seg2 = U16(b, sub + 6)
69 segs = seg2 // 2
70 ends = sub + 14
71 starts = ends + seg2 + 2
72 deltas = starts + seg2
73 ranges = deltas + seg2
74 for s in range(segs):
75 if U16(b, ends + s * 2) >= cp:
76 start = U16(b, starts + s * 2)
77 if cp < start:
78 return 0
79 delta = S16(b, deltas + s * 2)
80 ro = U16(b, ranges + s * 2)
81 if ro == 0:
82 return (cp + delta) & 0xFFFF
83 idx = ranges + s * 2 + ro + (cp - start) * 2
84 if idx + 2 > len(b):
85 return 0
86 g = U16(b, idx)
87 return 0 if g == 0 else (g + delta) & 0xFFFF
88 return 0
89 if fmt == 12:
90 ngroups = U32(b, sub + 12)
91 for g in range(ngroups):
92 e = sub + 16 + g * 12
93 lo, hi, sg = U32(b, e), U32(b, e + 4), U32(b, e + 8)
94 if lo <= cp <= hi:
95 return min(sg + cp - lo, 0xFFFF)
96 return 0
97 return None
98
99
100def kern_pairs(b, tb):
101 """Every (left, right) -> value in the font's own kern table."""
102 if tb is None:
103 return {}
104 base, _ = tb
105 if U16(b, base) != 0:
106 return {}
107 n = U16(b, base + 2)
108 off = base + 4
109 out = {}
110 for _ in range(n):
111 length = U16(b, off + 2)
112 cov = U16(b, off + 4)
113 if cov & 1 and (cov >> 8) == 0:
114 npairs = U16(b, off + 6)
115 for p in range(npairs):
116 e = off + 14 + p * 6
117 if e + 6 > len(b):
118 break
119 out.setdefault((U16(b, e), U16(b, e + 2)), S16(b, e + 4))
120 if length < 6:
121 break
122 off += length
123 return out
124
125
126def contour_stats(b, tabs, gid, upem, long_loca, num_glyphs, depth=0):
127 """(contours, points, on_curve_points, simple, bbox) with composites resolved.
128
129 bbox is over decoded points and is only reported for simple glyphs, where it
130 exercises the flag-repeat and x/y delta decoding end to end."""
131 if depth > 6 or gid >= num_glyphs:
132 return None
133 lo, ll = tabs["loca"]
134 go, gl = tabs["glyf"]
135 if long_loca:
136 a, z = U32(b, lo + gid * 4), U32(b, lo + gid * 4 + 4)
137 else:
138 a, z = U16(b, lo + gid * 2) * 2, U16(b, lo + gid * 2 + 2) * 2
139 if z <= a:
140 return (0, 0, 0, True, None)
141 p = go + a
142 nc = S16(b, p)
143 if nc < 0:
144 off = p + 10
145 tot = [0, 0, 0]
146 for _ in range(32):
147 flags, sub = U16(b, off), U16(b, off + 2)
148 off += 4
149 off += 4 if flags & 1 else 2
150 if flags & 8:
151 off += 2
152 elif flags & 0x40:
153 off += 4
154 elif flags & 0x80:
155 off += 8
156 r = contour_stats(b, tabs, sub, upem, long_loca, num_glyphs, depth + 1)
157 if r:
158 tot = [tot[i] + r[i] for i in range(3)]
159 if not flags & 0x20:
160 break
161 return (tot[0], tot[1], tot[2], False, None)
162 p += 10
163 ends = [U16(b, p + i * 2) for i in range(nc)]
164 p += nc * 2
165 npts = ends[-1] + 1 if ends else 0
166 ins = U16(b, p)
167 p += 2 + ins
168 flags = []
169 while len(flags) < npts:
170 f = b[p]
171 p += 1
172 flags.append(f)
173 if f & 8:
174 rep = b[p]
175 p += 1
176 flags.extend([f] * min(rep, npts - len(flags)))
177 on = sum(1 for f in flags if f & 1)
178 xs, v = [], 0
179 for f in flags:
180 if f & 2:
181 d = b[p]; p += 1
182 v += d if f & 16 else -d
183 elif not f & 16:
184 v += S16(b, p); p += 2
185 xs.append(v)
186 ys, v = [], 0
187 for f in flags:
188 if f & 4:
189 d = b[p]; p += 1
190 v += d if f & 32 else -d
191 elif not f & 32:
192 v += S16(b, p); p += 2
193 ys.append(v)
194 box = [min(xs), min(ys), max(xs), max(ys)] if xs else None
195 return (nc, npts, on, True, box)
196
197
198def describe(path):
199 b = open(path, "rb").read()
200 tabs = tables(b)
201 for need in ("head", "hhea", "hmtx", "maxp", "loca", "glyf"):
202 if need not in tabs:
203 return None
204 head = tabs["head"][0]
205 if U32(b, head + 12) != 0x5F0F3CF5:
206 return None
207 upem = U16(b, head + 18)
208 long_loca = S16(b, head + 50) == 1
209 num_glyphs = U16(b, tabs["maxp"][0] + 4)
210 hhea = tabs["hhea"][0]
211 nhm = U16(b, hhea + 34)
212 hmtx = tabs["hmtx"][0]
213
214 def adv(g):
215 return U16(b, hmtx + min(g, nhm - 1) * 4) if nhm else 0
216
217 cmap = tabs.get("cmap")
218 glyphs = {}
219 if cmap:
220 for ch in PROBE:
221 g = cmap_lookup(b, cmap[0], ch)
222 if g is None:
223 continue
224 glyphs[ch] = g
225 out = {
226 "file": path,
227 "units_per_em": upem,
228 "num_glyphs": num_glyphs,
229 "ascent": S16(b, hhea + 4),
230 "descent": S16(b, hhea + 6),
231 "line_gap": S16(b, hhea + 8),
232 "bbox": [S16(b, head + 36), S16(b, head + 38), S16(b, head + 40), S16(b, head + 42)],
233 "has_cmap": bool(cmap),
234 "glyph_ids": glyphs,
235 "advances": {str(g): adv(g) for g in sorted(set(glyphs.values()))},
236 "contours": {},
237 "kerning": {},
238 }
239 for ch, g in sorted(glyphs.items()):
240 st = contour_stats(b, tabs, g, upem, long_loca, num_glyphs)
241 if st:
242 out["contours"][str(g)] = st
243 # Sample real pairs out of the font's own kern table, spread across it so the
244 # binary search is exercised at both ends, plus pairs known to be absent.
245 pairs = kern_pairs(b, tabs.get("kern"))
246 if pairs:
247 keys = sorted(pairs)
248 step = max(1, len(keys) // 40)
249 for k in keys[::step][:40]:
250 out["kerning"][f"{k[0]},{k[1]}"] = pairs[k]
251 out["kerning"][f"{keys[0][0]},{keys[0][1]}"] = pairs[keys[0]]
252 out["kerning"][f"{keys[-1][0]},{keys[-1][1]}"] = pairs[keys[-1]]
253 for miss in ((0, 0), (65535, 65534), (7, 65533)):
254 if miss not in pairs:
255 out["kerning"][f"{miss[0]},{miss[1]}"] = 0
256 return out
257
258
259def emit(d):
260 w = sys.stdout.write
261 w(f"FONT {d['file']}\n")
262 w(f"UPEM {d['units_per_em']}\n")
263 w(f"NGLYPHS {d['num_glyphs']}\n")
264 w(f"VMETRICS {d['ascent']} {d['descent']} {d['line_gap']}\n")
265 w("BBOX %d %d %d %d\n" % tuple(d["bbox"]))
266 w(f"CMAP {int(d['has_cmap'])}\n")
267 for ch, g in sorted(d["glyph_ids"].items()):
268 w(f"GID {ord(ch)} {g}\n")
269 for g, a in sorted(d["advances"].items(), key=lambda kv: int(kv[0])):
270 w(f"ADV {g} {a}\n")
271 for g, st in sorted(d["contours"].items(), key=lambda kv: int(kv[0])):
272 nc, npts, non, simple, box = st
273 if simple and box:
274 w(f"GLYPH {g} {nc} {npts} {non} 1 {box[0]} {box[1]} {box[2]} {box[3]}\n")
275 else:
276 w(f"GLYPH {g} {nc} {npts} {non} 0 0 0 0 0\n")
277 for k, v in sorted(d["kerning"].items()):
278 l, r = k.split(",")
279 w(f"KERN {l} {r} {v}\n")
280 w("END\n")
281
282
283def main():
284 n = 0
285 for path in sys.argv[1:]:
286 try:
287 d = describe(path)
288 except Exception as exc:
289 print(f"skip {path}: {exc}", file=sys.stderr)
290 continue
291 if d:
292 emit(d)
293 n += 1
294 print(f"{n} fonts described", file=sys.stderr)
295
296
297main()