nnnotes 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. nnnotes/__init__.py +7 -0
  2. nnnotes/__main__.py +4 -0
  3. nnnotes/acb.py +190 -0
  4. nnnotes/addressables.py +413 -0
  5. nnnotes/adv.py +243 -0
  6. nnnotes/advcommand.py +29 -0
  7. nnnotes/advmedia.py +178 -0
  8. nnnotes/advscene.py +113 -0
  9. nnnotes/advui.py +1154 -0
  10. nnnotes/advvideo.py +684 -0
  11. nnnotes/atoms/__init__.py +151 -0
  12. nnnotes/atoms/astc.py +52 -0
  13. nnnotes/atoms/gltf.py +290 -0
  14. nnnotes/atoms/mesh.py +289 -0
  15. nnnotes/atoms/png.py +28 -0
  16. nnnotes/atoms/reader.py +269 -0
  17. nnnotes/atoms/sniff.py +198 -0
  18. nnnotes/atoms/sprite.py +236 -0
  19. nnnotes/atoms/texture.py +80 -0
  20. nnnotes/cache.py +289 -0
  21. nnnotes/catalog.py +373 -0
  22. nnnotes/catalogdb.py +457 -0
  23. nnnotes/census.py +276 -0
  24. nnnotes/cli.py +744 -0
  25. nnnotes/cli_assets.py +1539 -0
  26. nnnotes/config.py +238 -0
  27. nnnotes/contract.py +497 -0
  28. nnnotes/cri.py +371 -0
  29. nnnotes/crikey.py +71 -0
  30. nnnotes/crilips.py +265 -0
  31. nnnotes/cristages.py +558 -0
  32. nnnotes/deckdata.py +462 -0
  33. nnnotes/export.py +1462 -0
  34. nnnotes/gameapi.py +318 -0
  35. nnnotes/jsonio.py +98 -0
  36. nnnotes/languages.py +44 -0
  37. nnnotes/layout.py +596 -0
  38. nnnotes/link.py +418 -0
  39. nnnotes/live.py +115 -0
  40. nnnotes/live2d.py +235 -0
  41. nnnotes/liveaudio.py +331 -0
  42. nnnotes/livenotes.py +291 -0
  43. nnnotes/liveoptions.py +250 -0
  44. nnnotes/livescene.py +450 -0
  45. nnnotes/liveui.py +328 -0
  46. nnnotes/master.py +251 -0
  47. nnnotes/modelstages.py +424 -0
  48. nnnotes/motion.py +131 -0
  49. nnnotes/objexport.py +1564 -0
  50. nnnotes/orchestrate.py +630 -0
  51. nnnotes/plan.py +218 -0
  52. nnnotes/player.py +392 -0
  53. nnnotes/room.py +391 -0
  54. nnnotes/score.py +1139 -0
  55. nnnotes/shader.py +217 -0
  56. nnnotes/spot.py +201 -0
  57. nnnotes/stages.py +285 -0
  58. nnnotes/store.py +518 -0
  59. nnnotes/story.py +142 -0
  60. nnnotes/storyfonts.py +1258 -0
  61. nnnotes/storyhost.py +1190 -0
  62. nnnotes/storysite.py +662 -0
  63. nnnotes/storyvoices.py +102 -0
  64. nnnotes/textstyle.py +355 -0
  65. nnnotes/tmpfont.py +636 -0
  66. nnnotes/typetrees/6000.3.12f1.json +19 -0
  67. nnnotes/unity.py +368 -0
  68. nnnotes/viewrules.json +441 -0
  69. nnnotes/views.py +966 -0
  70. nnnotes/voices.py +1363 -0
  71. nnnotes/web.py +1624 -0
  72. nnnotes/webaudio.py +14 -0
  73. nnnotes/webmodel.py +397 -0
  74. nnnotes-0.1.0.dist-info/METADATA +200 -0
  75. nnnotes-0.1.0.dist-info/RECORD +79 -0
  76. nnnotes-0.1.0.dist-info/WHEEL +5 -0
  77. nnnotes-0.1.0.dist-info/entry_points.txt +2 -0
  78. nnnotes-0.1.0.dist-info/licenses/LICENSE +21 -0
  79. nnnotes-0.1.0.dist-info/top_level.txt +1 -0
nnnotes/tmpfont.py ADDED
@@ -0,0 +1,636 @@
1
+ """TextMeshPro font assets for the UI exports with game fonts (`--fonts game`).
2
+
3
+ FontSet TMP font assets reachable from the localized fonts (fallback chains) with TMP
4
+ character lookup; RuntimeGlyphs generates the SDF glyphs a dynamic font asset adds
5
+ at runtime (FontEngine) from the source font file, validated against the baked ones
6
+ glyph_coverage / export_fonts / resolve_font_blocks
7
+ the character and glyph tables reduced to the characters a page shows, their texel
8
+ blocks copied into packed textures (export.TexelPacker)
9
+
10
+ The localized font / material / line spacing of a text (LocalizeText) and the text records are in textstyle.py.
11
+ Needs the optional dependencies of the `fonts` extra (require_extra).
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import importlib.util
17
+ import io
18
+ import math
19
+
20
+ import numpy as np
21
+
22
+ from .config import ConfigError
23
+ from .export import Exporter, TexelPacker, _key, grow_box
24
+ from .textstyle import font_dir
25
+
26
+ EXTRA = "fonts"
27
+ EXTRA_MODULES = {"fontTools": "fonttools", "freetype": "freetype-py", "scipy": "scipy"}
28
+
29
+ # TextMeshPro synthesized control characters (TMP_FontAsset.AddSynthesizedCharactersAndFaceMetrics):
30
+ # zero-metric glyphs used when the font file does not map them.
31
+ TMP_SYNTHESIZED = (0x03, 0x09, 0x0A, 0x0B, 0x0D, 0x061C, 0x200B, 0x200E, 0x200F, 0x2028, 0x2029, 0x2060)
32
+
33
+
34
+ def require_extra(what: str = "TextMesh Pro font assets") -> None:
35
+ """Raise ConfigError naming the `fonts` extra when one of its packages is not installed; `what` names what needs
36
+ them (an option, or the font assets)."""
37
+ missing = [pkg for mod, pkg in EXTRA_MODULES.items() if importlib.util.find_spec(mod) is None]
38
+ if missing:
39
+ raise ConfigError(f"{what}: the optional '{EXTRA}' dependencies are needed ({', '.join(missing)} not "
40
+ f"installed): pip install 'nnnotes[{EXTRA}]'")
41
+
42
+
43
+ # --------------------------------------------------------------------------
44
+ # fonts
45
+ # --------------------------------------------------------------------------
46
+ class Font:
47
+ def __init__(self, ex: Exporter, obj):
48
+ self.obj = obj
49
+ tt = obj.read_typetree()
50
+ self.tt = tt
51
+ self.name = tt["m_Name"]
52
+ self.chars = {c["m_Unicode"]: c for c in tt["m_CharacterTable"]}
53
+ self.glyphs = {g["m_Index"]: g for g in tt["m_GlyphTable"]}
54
+ self.dynamic = tt["m_AtlasPopulationMode"] == 1
55
+ self.atlases = [ex.deref(obj, p) for p in tt["m_AtlasTextures"]]
56
+ self.material_obj = ex.deref(obj, tt["m_Material"])
57
+ self.fallback_objs = [ex.deref(obj, p) for p in tt["m_FallbackFontAssetTable"]]
58
+ if any(p["regularTypeface"]["m_PathID"] or p["italicTypeface"]["m_PathID"] for p in tt["m_FontWeightTable"]):
59
+ raise NotImplementedError(f"{self.name}: font weight table typefaces")
60
+ self.cmap: set[int] = set()
61
+ self.variants: dict[tuple[int, int], str] = {} # (base, selector) -> a variant glyph other than the base's
62
+ self.font_data: bytes | None = None
63
+ src = ex.deref(obj, tt["m_SourceFontFile"]) if tt["m_SourceFontFile"]["m_PathID"] else None
64
+ if src is not None:
65
+ from fontTools.ttLib import TTFont
66
+ data = bytes(src.read_typetree()["m_FontData"])
67
+ ft = TTFont(io.BytesIO(data), fontNumber=tt["m_FaceInfo"]["m_FaceIndex"], lazy=True)
68
+ best = ft.getBestCmap() or {}
69
+ self.cmap = set(best)
70
+ for t in ft["cmap"].tables: # variation sequences (cmap format 14), non-default ones
71
+ if t.format == 14:
72
+ for vs, entries in t.uvsDict.items():
73
+ for base, glyph in entries:
74
+ if glyph is not None and glyph != best.get(base):
75
+ self.variants[(base, vs)] = glyph
76
+ self.font_data = data
77
+ self.source_font = {"name": src.read_typetree()["m_Name"], "bytes": len(data),
78
+ "sha256": hashlib.sha256(data).hexdigest()}
79
+ else:
80
+ self.source_font = None
81
+
82
+
83
+ class FontSet:
84
+ """Font assets reachable from the localized fonts, with TMP character lookup."""
85
+
86
+ def __init__(self, ex: Exporter):
87
+ self.ex = ex
88
+ self.fonts: dict[str, Font] = {}
89
+ self._by_obj: dict[tuple, Font] = {}
90
+
91
+ def by_key(self, key: str) -> Font:
92
+ return self.of(self.ex.key_object(key))
93
+
94
+ def of(self, obj) -> Font:
95
+ k = _key(obj)
96
+ if k not in self._by_obj:
97
+ f = Font(self.ex, obj)
98
+ if f.name in self.fonts:
99
+ raise RuntimeError(f"two font assets named {f.name}")
100
+ self._by_obj[k] = f
101
+ self.fonts[f.name] = f
102
+ for fb in f.fallback_objs:
103
+ if fb is not None:
104
+ self.of(fb)
105
+ return self._by_obj[k]
106
+
107
+ def fallbacks(self, f: Font) -> list[Font]:
108
+ return [self._by_obj[_key(o)] for o in f.fallback_objs if o is not None]
109
+
110
+ def _in_font(self, f: Font, u: int):
111
+ """GetCharacterFromFontAsset_Internal without fallbacks (regular weight, no italic)."""
112
+ if u in f.chars:
113
+ return ("baked", f)
114
+ if u in TMP_SYNTHESIZED and not (f.dynamic and u in f.cmap):
115
+ return ("synthesized", f)
116
+ if f.dynamic and u in f.cmap:
117
+ return ("runtime", f) # TryAddCharacterInternal: SDF rendered at runtime
118
+ return None
119
+
120
+ def _search(self, f: Font, u: int, searched: set):
121
+ r = self._in_font(f, u)
122
+ if r:
123
+ return r
124
+ for fb in self.fallbacks(f):
125
+ if fb.name in searched:
126
+ continue
127
+ searched.add(fb.name)
128
+ r = self._search(fb, u, searched)
129
+ if r:
130
+ return r
131
+ return None
132
+
133
+ def lookup(self, primary: Font, u: int):
134
+ """TMP_Text.GetTextElement for a font asset with no global fallbacks set."""
135
+ r = self._in_font(primary, u)
136
+ if r:
137
+ return r
138
+ searched: set = set()
139
+ for fb in self.fallbacks(primary):
140
+ if fb.name in searched:
141
+ continue
142
+ searched.add(fb.name)
143
+ r = self._search(fb, u, searched)
144
+ if r:
145
+ return r
146
+ return None
147
+
148
+ def baked_anywhere(self, u: int) -> list[str]:
149
+ return sorted(f.name for f in self.fonts.values() if u in f.chars)
150
+
151
+
152
+ # --------------------------------------------------------------------------
153
+ # missing glyph
154
+ # --------------------------------------------------------------------------
155
+ MISSING_GLYPH_CHARACTER = 0x25A1 # TMP_Settings.missingGlyphCharacter 0: U+25A1 (TMP_Text.SetArraySizes)
156
+ SPACE, END_OF_TEXT = 0x20, 0x03
157
+
158
+
159
+ def is_variation_selector(u: int) -> bool:
160
+ """U+FE00..U+FE0F, U+E0100..U+E01EF (SetArraySizes: next >> 4 == 0xFE0 or next - 0xE0100 < 0xF0)."""
161
+ return u >> 4 == 0xFE0 or 0xE0100 <= u < 0xE01F0
162
+
163
+
164
+ def missing_glyph_character(player) -> int:
165
+ """TMP_Settings.missingGlyphCharacter of the game's TMP Settings (0: U+25A1). FontSet.lookup searches no global
166
+ fallback: the settings' fallback font assets and default font asset must be unset."""
167
+ o = player.resource("TMP Settings")
168
+ tt = player.mono(o)
169
+ if tt["m_fallbackFontAssets"] or tt["m_defaultFontAsset"]["m_PathID"]:
170
+ raise NotImplementedError("TMP Settings fallback font assets / default font asset")
171
+ return tt["m_missingGlyphCharacter"] or MISSING_GLYPH_CHARACTER
172
+
173
+
174
+ def missing_glyph(fonts: FontSet, font: Font, chars, character: int = MISSING_GLYPH_CHARACTER) -> dict | None:
175
+ """The missing glyph of a text of `font` (TMP_Text.SetArraySizes, a character GetTextElement finds in no font asset
176
+ and no sprite asset): {unicode: the substitute, characters: the code points of `chars` that `font` and its
177
+ fallbacks lack}, or None when they have every one. The substitute is `character` (missing_glyph_character) when
178
+ the font or its fallbacks have it, else U+0020, else U+0003 (synthesized). A text with a sprite asset draws the
179
+ code points the sprite asset has from it instead."""
180
+ miss = sorted(u for u in chars if fonts.lookup(font, u) is None)
181
+ if not miss:
182
+ return None
183
+ sub = next(u for u in (character, SPACE, END_OF_TEXT) if fonts.lookup(font, u) is not None)
184
+ return {"unicode": sub, "characters": miss}
185
+
186
+
187
+ def underline_character(fonts: FontSet, font: Font) -> bool:
188
+ """Whether `font` itself has U+005F (TMP_Text.GetUnderlineSpecialCharacter: GetCharacterFromFontAsset without
189
+ fallbacks; a dynamic asset adds it from its source font): the glyph its texts' underline and highlight draw with."""
190
+ r = fonts._in_font(font, 0x5F)
191
+ return r is not None and r[0] in ("baked", "runtime")
192
+
193
+
194
+ def check_glyph_variants(fonts: FontSet, font: Font, texts) -> None:
195
+ """Raise NotImplementedError when a character of `texts` that `font` (or a fallback) has is followed by a variation
196
+ selector its source font maps to a variant glyph (SetArraySizes: GetGlyphVariantIndex -> the alternative glyph);
197
+ any other selector after a font character is dropped (rewritten to U+001A)."""
198
+ for s in texts:
199
+ cps = [ord(c) for c in s]
200
+ for base, vs in zip(cps, cps[1:]):
201
+ if is_variation_selector(vs):
202
+ r = fonts.lookup(font, base)
203
+ if r is not None and (base, vs) in r[1].variants:
204
+ raise NotImplementedError(f"{r[1].name}: glyph variant of U+{base:04X} U+{vs:04X}")
205
+
206
+
207
+ # --------------------------------------------------------------------------
208
+ # runtime glyphs of dynamic font assets
209
+ # --------------------------------------------------------------------------
210
+ # UnityEngine.TextCore.LowLevel.GlyphRasterModes: a GlyphRenderMode
211
+ # is a set of these flags; SDF = 4134 = RASTER_MODE_1X | RASTER_MODE_SDF | RASTER_MODE_NO_HINTING |
212
+ # RASTER_MODE_MONO.
213
+ RASTER_MODE = {"8BIT": 1, "MONO": 2, "NO_HINTING": 4, "HINTED": 8, "BITMAP": 16, "SDF": 32, "SDFAA": 64,
214
+ "1X": 4096, "8X": 8192, "16X": 16384, "32X": 32768}
215
+ GLYPH_METRIC_FIELDS = ("m_Width", "m_Height", "m_HorizontalBearingX", "m_HorizontalBearingY",
216
+ "m_HorizontalAdvance")
217
+
218
+
219
+ class RuntimeGlyphs:
220
+ """Glyphs TMP adds to a dynamic font asset at runtime (TMP_FontAsset.TryAddCharacterInternal ->
221
+ FontEngine.TryAddGlyphToTexture), generated from the source font file.
222
+
223
+ FontEngine is native (not in the IL2CPP code), so the algorithm is the one that reproduces
224
+ the baked atlas of the same font asset (checked by validate()):
225
+ - face at pointSize px/em (FT_Set_Char_Size(pointSize*64) at 72 dpi), outline loaded without
226
+ hinting; metrics are FreeType's 26.6 glyph metrics;
227
+ - glyph rect = the metrics box with each edge rounded to the nearest pixel, ties outwards;
228
+ - binary raster = FreeType monochrome rasterizer (FT_RENDER_MODE_MONO: pixel centres inside
229
+ the outline) placed on the rect + padding grid (glyph origin at the rounded box corner);
230
+ - exact Euclidean distance transform between pixel centres on that grid: inside pixels get
231
+ the distance to the nearest outside pixel, outside pixels to the nearest inside pixel;
232
+ - alpha = round(255 * clamp01(0.5 + (dIn - dOut) / (2 * (padding + 1)))), i.e. the spread is
233
+ padding + 1 texels (= the material _GradientScale) on each side.
234
+ """
235
+
236
+ ALGORITHM = ("FreeType monochrome raster (FT_RENDER_MODE_MONO, FT_LOAD_NO_HINTING, 1x) + exact Euclidean "
237
+ "distance transform between texel centres (scipy.ndimage.distance_transform_edt)")
238
+ MAPPING = "alpha8 = round(255 * clamp01(0.5 + (dIn - dOut) / (2 * (padding + 1)))), distances in texels"
239
+ ALIGNMENT = ("rect = metrics box (bearingX, bearingY - height, width, height) with each edge rounded to the "
240
+ "nearest pixel, ties outwards; the raster's pixel (i, j) of the rect is the glyph-space pixel "
241
+ "[xMin + i, xMin + i + 1) x [yMin + j, yMin + j + 1) at pointSize px/em, texel centres sampled; "
242
+ "the SDF block is the rect grown by padding on each side")
243
+
244
+ def __init__(self, font: Font):
245
+ import freetype
246
+ from scipy.ndimage import distance_transform_edt
247
+ self.ft = freetype
248
+ self.edt = distance_transform_edt
249
+ tt = font.tt
250
+ mode = tt["m_AtlasRenderMode"]
251
+ want = RASTER_MODE["1X"] | RASTER_MODE["SDF"] | RASTER_MODE["NO_HINTING"] | RASTER_MODE["MONO"]
252
+ if mode != want:
253
+ raise NotImplementedError(f"{font.name}: runtime glyphs for render mode {mode}")
254
+ if font.font_data is None:
255
+ raise RuntimeError(f"{font.name}: dynamic font asset without a source font file")
256
+ self.font = font
257
+ self.pad = tt["m_AtlasPadding"]
258
+ self.spread = self.pad + 1
259
+ self.point_size = tt["m_FaceInfo"]["m_PointSize"]
260
+ if self.point_size != int(self.point_size):
261
+ raise NotImplementedError(f"{font.name}: fractional point size {self.point_size}")
262
+ self.face = freetype.Face(io.BytesIO(font.font_data), tt["m_FaceInfo"]["m_FaceIndex"])
263
+ self.face.set_char_size(int(self.point_size) * 64, 0, 72, 72)
264
+ if self.face.units_per_EM != tt["m_FaceInfo"]["m_UnitsPerEM"]:
265
+ raise RuntimeError(f"{font.name}: units per EM differ from the face info")
266
+
267
+ oversample = 1 # raster pixels per texel (cell(); 1: the runtime rasterization above)
268
+
269
+ @classmethod
270
+ def from_font_file(cls, data: bytes, face_index: int, point_size: int, padding: int,
271
+ oversample: int = 1) -> "RuntimeGlyphs":
272
+ """The same generator for a font file of one's own (open font assets, storyfonts.py): `point_size` px per
273
+ em, `padding` texels of distance field (spread padding + 1). `oversample` > 1: cell() rasterizes at that
274
+ many times the point size and takes the distance at each texel centre from the finer grid (the
275
+ supersampled GlyphRenderModes). The glyph metrics and rects are those at `point_size` in every case."""
276
+ import freetype
277
+ from scipy.ndimage import distance_transform_edt
278
+ if point_size != int(point_size) or point_size <= 0:
279
+ raise ValueError(f"point size {point_size}: a positive whole number of px per em")
280
+ if oversample < 1 or oversample != int(oversample):
281
+ raise ValueError(f"oversample {oversample}")
282
+ g = cls.__new__(cls)
283
+ g.ft, g.edt, g.font = freetype, distance_transform_edt, None
284
+ g.pad, g.spread, g.point_size, g.oversample = int(padding), int(padding) + 1, int(point_size), int(oversample)
285
+ g.face = freetype.Face(io.BytesIO(data), face_index)
286
+ g.face.set_char_size(g.point_size * 64, 0, 72, 72)
287
+ g.fine = g.face
288
+ if g.oversample > 1:
289
+ g.fine = freetype.Face(io.BytesIO(data), face_index)
290
+ g.fine.set_char_size(g.point_size * g.oversample * 64, 0, 72, 72)
291
+ return g
292
+
293
+ def cell(self, gi: int, margin: int) -> tuple[dict, tuple, np.ndarray]:
294
+ """-> (metrics, (xMin, yMin, w, h) rect box in glyph pixels, alpha of the rect grown by `margin` texels on
295
+ each side (h + 2 margin, w + 2 margin), bottom row first). The distance field is computed over the whole
296
+ cell (`margin` >= padding), with the mapping of sdf(); with oversample k the binary raster is k x k pixels
297
+ per texel and a texel's distance is the mean of the k-grid distances at its centre, divided by k."""
298
+ if margin < self.pad:
299
+ raise ValueError(f"margin {margin} < padding {self.pad}")
300
+ ft, k = self.ft, self.oversample
301
+ m = self.metrics(gi)
302
+ xmin, ymin, w, h = self.pixel_box(m)
303
+ rows, cols = h + 2 * margin, w + 2 * margin
304
+ self.fine.load_glyph(gi, ft.FT_LOAD_NO_HINTING)
305
+ self.fine.glyph.render(ft.FT_RENDER_MODE_MONO)
306
+ bm = self.fine.glyph.bitmap
307
+ grid = np.zeros((rows * k, cols * k), bool)
308
+ if bm.rows and bm.width:
309
+ if bm.pixel_mode != ft.FT_PIXEL_MODE_MONO:
310
+ raise RuntimeError(f"glyph {gi}: FreeType returned pixel mode {bm.pixel_mode}")
311
+ raw = np.frombuffer(bytes(bm.buffer), np.uint8).reshape(bm.rows, bm.pitch)
312
+ bits = np.unpackbits(raw, axis=1)[:, :bm.width].astype(bool)[::-1] # bottom row first
313
+ ox = self.fine.glyph.bitmap_left - (xmin - margin) * k
314
+ oy = (self.fine.glyph.bitmap_top - bm.rows) - (ymin - margin) * k
315
+ if ox < 0 or oy < 0 or ox + bm.width > grid.shape[1] or oy + bm.rows > grid.shape[0]:
316
+ raise RuntimeError(f"glyph {gi}: raster outside the cell")
317
+ grid[oy:oy + bm.rows, ox:ox + bm.width] = bits
318
+ if not grid.any():
319
+ return m, (xmin, ymin, w, h), np.zeros((rows, cols), np.uint8)
320
+ d = self.edt(grid) - self.edt(~grid)
321
+ if k > 1:
322
+ lo, hi = (k - 1) // 2, k // 2 + 1 # the fine pixels whose centres are nearest the texel's
323
+ d = d.reshape(rows, k, cols, k)[:, lo:hi, :, lo:hi].mean(axis=(1, 3)) / k
324
+ a = np.floor(255.0 * np.clip(0.5 + d / (2.0 * self.spread), 0.0, 1.0) + 0.5).astype(np.uint8)
325
+ return m, (xmin, ymin, w, h), a
326
+
327
+ def glyph_index(self, u: int) -> int:
328
+ return self.face.get_char_index(u)
329
+
330
+ def metrics(self, gi: int) -> dict:
331
+ self.face.load_glyph(gi, self.ft.FT_LOAD_NO_HINTING)
332
+ m = self.face.glyph.metrics
333
+ return {"m_Width": m.width / 64, "m_Height": m.height / 64, "m_HorizontalBearingX": m.horiBearingX / 64,
334
+ "m_HorizontalBearingY": m.horiBearingY / 64, "m_HorizontalAdvance": m.horiAdvance / 64}
335
+
336
+ @staticmethod
337
+ def pixel_box(m: dict) -> tuple[int, int, int, int]:
338
+ x0 = m["m_HorizontalBearingX"]; x1 = x0 + m["m_Width"]
339
+ y1 = m["m_HorizontalBearingY"]; y0 = y1 - m["m_Height"]
340
+ xmin, ymin = math.ceil(x0 - 0.5), math.ceil(y0 - 0.5)
341
+ return xmin, ymin, math.floor(x1 + 0.5) - xmin, math.floor(y1 + 0.5) - ymin
342
+
343
+ def sdf(self, gi: int) -> tuple[dict, tuple, np.ndarray]:
344
+ """-> (metrics, (xMin, yMin, w, h) rect box in glyph pixels, alpha block (h+2p, w+2p) bottom row first)."""
345
+ ft = self.ft
346
+ m = self.metrics(gi)
347
+ xmin, ymin, w, h = self.pixel_box(m)
348
+ p = self.pad
349
+ self.face.load_glyph(gi, ft.FT_LOAD_NO_HINTING)
350
+ self.face.glyph.render(ft.FT_RENDER_MODE_MONO)
351
+ bm = self.face.glyph.bitmap
352
+ grid = np.zeros((h + 2 * p, w + 2 * p), bool)
353
+ if bm.rows and bm.width:
354
+ if bm.pixel_mode != ft.FT_PIXEL_MODE_MONO:
355
+ raise RuntimeError(f"glyph {gi}: FreeType returned pixel mode {bm.pixel_mode}")
356
+ raw = np.frombuffer(bytes(bm.buffer), np.uint8).reshape(bm.rows, bm.pitch)
357
+ bits = np.unpackbits(raw, axis=1)[:, :bm.width].astype(bool)[::-1] # bottom row first
358
+ ox = self.face.glyph.bitmap_left - (xmin - p)
359
+ oy = (self.face.glyph.bitmap_top - bm.rows) - (ymin - p)
360
+ if ox < 0 or oy < 0 or ox + bm.width > grid.shape[1] or oy + bm.rows > grid.shape[0]:
361
+ raise RuntimeError(f"glyph {gi}: raster outside the padded rect")
362
+ grid[oy:oy + bm.rows, ox:ox + bm.width] = bits
363
+ if not grid.any():
364
+ return m, (xmin, ymin, w, h), np.zeros(grid.shape, np.uint8)
365
+ d = self.edt(grid) - self.edt(~grid)
366
+ a = np.floor(255.0 * np.clip(0.5 + d / (2.0 * self.spread), 0.0, 1.0) + 0.5).astype(np.uint8)
367
+ return m, (xmin, ymin, w, h), a
368
+
369
+ def validate(self, packer: TexelPacker, units) -> dict:
370
+ """Generate the baked characters `units` of the same font asset and compare with its atlas."""
371
+ f, p = self.font, self.pad
372
+ errs, per, metric_diff = [], {}, {k: 0.0 for k in GLYPH_METRIC_FIELDS}
373
+ index_mismatch, rect_mismatch = [], []
374
+ for u in sorted(units):
375
+ c = f.chars[u]
376
+ g = f.glyphs[c["m_GlyphIndex"]]
377
+ if self.glyph_index(u) != c["m_GlyphIndex"]:
378
+ index_mismatch.append(u)
379
+ gm = self.metrics(c["m_GlyphIndex"])
380
+ for k in GLYPH_METRIC_FIELDS:
381
+ metric_diff[k] = max(metric_diff[k], abs(gm[k] - g["m_Metrics"][k]))
382
+ gr = g["m_GlyphRect"]
383
+ if g["m_Metrics"]["m_Width"] <= 0 or g["m_Metrics"]["m_Height"] <= 0:
384
+ continue # no outline (space): nothing rendered
385
+ m, (xmin, ymin, w, h), a = self.sdf(c["m_GlyphIndex"])
386
+ if (w, h) != (gr["m_Width"], gr["m_Height"]):
387
+ rect_mismatch.append(u)
388
+ continue
389
+ atlas = packer.pixels(f.atlases[g["m_AtlasIndex"]])[:, :, 3]
390
+ y0, x0 = gr["m_Y"] - p, gr["m_X"] - p
391
+ ref = atlas[y0:y0 + h + 2 * p, x0:x0 + w + 2 * p].astype(np.int32)
392
+ if ref.shape != a.shape:
393
+ raise RuntimeError(f"{f.name}: glyph {c['m_GlyphIndex']} block leaves the atlas")
394
+ e = np.abs(a.astype(np.int32) - ref)
395
+ errs.append(e.ravel())
396
+ per[chr(u)] = {"max": int(e.max()), "mean": float(e.mean()), "differing": int((e > 0).sum()),
397
+ "texels": int(e.size)}
398
+ allerr = np.concatenate(errs) if errs else np.zeros(0, np.int32)
399
+ worst = sorted(per.items(), key=lambda kv: (-kv[1]["max"], -kv[1]["differing"]))[:5]
400
+ return {"glyphs": len(per), "texels": int(allerr.size),
401
+ "maxAbsError": int(allerr.max()) if allerr.size else 0,
402
+ "meanAbsError": round(float(allerr.mean()), 6) if allerr.size else 0.0,
403
+ "texelsDiffering": int((allerr > 0).sum()),
404
+ "exactGlyphs": sum(1 for v in per.values() if v["max"] == 0),
405
+ "worstGlyphs": [{"char": ch, **v} for ch, v in worst],
406
+ "metricsMaxAbsDiff": metric_diff, "glyphIndexMismatches": index_mismatch,
407
+ "rectSizeMismatches": rect_mismatch,
408
+ "units": "alpha8 levels over the glyph rect grown by padding; metrics in pointSize px"}
409
+
410
+
411
+ def rgba_alpha(a: np.ndarray) -> np.ndarray:
412
+ out = np.zeros(a.shape + (4,), np.uint8)
413
+ out[:, :, 3] = a
414
+ return out
415
+
416
+
417
+ def material_by_name(ex: Exporter, font: Font, mat_name: str):
418
+ key = font_dir(font.name) + mat_name
419
+ if not ex.cat.has(key):
420
+ raise KeyError(f"material key {key}")
421
+ return ex.material(ex.key_object(key))
422
+
423
+
424
+ def runtime_glyphs(f: Font, extra: set, baked: set, margin: int, packer: TexelPacker,
425
+ chars_out: dict, glyphs_out: dict) -> dict:
426
+ """Characters of a dynamic font asset that TMP renders into the atlas at runtime: generate their
427
+ SDF glyphs + metrics from the source font, add them to the glyph/character tables and the packed
428
+ font texture, and validate the generator against the baked glyphs `baked` of the same asset."""
429
+ gen = RuntimeGlyphs(f)
430
+ p = gen.pad
431
+ if margin < p:
432
+ raise RuntimeError(f"{f.name}: sampling margin {margin} < padding {p}")
433
+ validation = gen.validate(packer, baked)
434
+ settings = [a.read_typetree()["m_TextureSettings"] for a in f.atlases if a is not None]
435
+ if not settings or any(s != settings[0] for s in settings):
436
+ raise RuntimeError(f"{f.name}: atlas pages with different sampler settings")
437
+ x = 0
438
+ generated = []
439
+ for u in sorted(extra):
440
+ gi = gen.glyph_index(u)
441
+ if gi == 0:
442
+ raise RuntimeError(f"{f.name}: U+{u:04X} not in the source font cmap")
443
+ if str(gi) in glyphs_out:
444
+ raise RuntimeError(f"{f.name}: runtime glyph {gi} already in the table")
445
+ m, (xmin, ymin, w, h), a = gen.sdf(gi)
446
+ # virtual atlas page: one cell per glyph, rect + margin on each side, cleared to 0
447
+ rect = {"m_X": x + margin, "m_Y": margin, "m_Width": w, "m_Height": h}
448
+ x += w + 2 * margin
449
+ rec = {"metrics": m, "rect": rect, "scale": 1.0, "atlasIndex": None, "runtime": True}
450
+ if w > 0 and h > 0:
451
+ cell = np.zeros((h + 2 * margin, w + 2 * margin), np.uint8)
452
+ cell[margin - p:margin - p + a.shape[0], margin - p:margin - p + a.shape[1]] = a
453
+ rec["_block"] = packer.add_array(f"font_{f.name}", f"runtime glyph {gi}", rgba_alpha(cell),
454
+ (rect["m_X"] - margin, rect["m_Y"] - margin), settings[0])
455
+ chars_out[str(u)] = {"glyph": gi, "scale": 1.0, "elementType": 1}
456
+ glyphs_out[str(gi)] = rec
457
+ generated.append({"char": chr(u), "unicode": u, "glyph": gi})
458
+ return {
459
+ "renderMode": {"value": f.tt["m_AtlasRenderMode"],
460
+ "flags": [k for k, v in RASTER_MODE.items() if f.tt["m_AtlasRenderMode"] & v]},
461
+ "pointSize": gen.point_size, "padding": p, "spread": gen.spread,
462
+ "algorithm": gen.ALGORITHM, "mapping": gen.MAPPING, "alignment": gen.ALIGNMENT,
463
+ "atlasPlacement": ("runtime state: rects are cells of a virtual page (atlasIndex null), one cell per "
464
+ "glyph with the sampling margin cleared to 0"),
465
+ "generated": generated,
466
+ "validation": validation,
467
+ "unresolved": [
468
+ "FontEngine is native code, so the algorithm is fitted to the baked atlas of this font asset. The "
469
+ "GlyphRasterModes flags of render mode 4134 name MONO | NO_HINTING | SDF | 1X, and every baked glyph's "
470
+ "texels equal the exact EDT of its own binary mask; the binary mask is taken from the installed "
471
+ "FreeType's mono rasterizer. FreeType 2.13.2's mono raster differs from the baked masks in a few pixels "
472
+ "whose centres lie within 0.12 px of curved outline parts (FreeType 2.10.3-2.13.2 give identical masks; "
473
+ "2.8-2.10.1 differ more).",
474
+ "Glyph rect size/placement rule (bbox edges rounded to the nearest pixel, ties outwards): fitted to the "
475
+ "baked rects (all match).",
476
+ "Runtime atlas placement: rect positions and the texels beyond the padding (neighbouring glyphs in "
477
+ "the real dynamic atlas) are runtime state; zeros are used (a cleared page). Only samples beyond the "
478
+ "padding (underlay offset, bilinear margin) can see them.",
479
+ "The baked atlas is generated in the editor; runtime glyphs are assumed to be rasterized the same way by "
480
+ "the player's FontEngine.",
481
+ ],
482
+ }
483
+
484
+
485
+ # --------------------------------------------------------------------------
486
+ # characters -> font assets, materials, glyph tables
487
+ # --------------------------------------------------------------------------
488
+ def glyph_coverage(fonts: FontSet, primary: Font, chars: list[int]) -> tuple[dict, dict, dict]:
489
+ """TMP character lookup of `chars` (distinct code points) from `primary` through its fallback chain.
490
+ -> (coverage report, {font: baked units}, {font: runtime units})."""
491
+ coverage = {"primaryFont": primary.name, "characters": len(chars), "baked": [], "fallbackBaked": {},
492
+ "runtimeGenerated": [], "synthesized": [], "missing": []}
493
+ needed: dict[str, set] = {}
494
+ runtime_units: dict[str, set] = {}
495
+ for u in chars:
496
+ r = fonts.lookup(primary, u)
497
+ ch = chr(u)
498
+ if r is None:
499
+ coverage["missing"].append(ch)
500
+ continue
501
+ kind, f = r
502
+ if kind == "baked":
503
+ needed.setdefault(f.name, set()).add(u)
504
+ if f is primary:
505
+ coverage["baked"].append(ch)
506
+ else:
507
+ coverage["fallbackBaked"].setdefault(f.name, []).append(ch)
508
+ elif kind == "synthesized":
509
+ coverage["synthesized"].append(f"U+{u:04X}")
510
+ else:
511
+ runtime_units.setdefault(f.name, set()).add(u)
512
+ coverage["runtimeGenerated"].append({"char": ch, "font": f.name,
513
+ "bakedIn": fonts.baked_anywhere(u)})
514
+ coverage["counts"] = {"distinct": len(chars), "bakedPrimary": len(coverage["baked"]),
515
+ "bakedFallbackOnly": sum(len(v) for v in coverage["fallbackBaked"].values()),
516
+ "runtimeGenerated": len(coverage["runtimeGenerated"]),
517
+ "synthesized": len(coverage["synthesized"]), "missing": len(coverage["missing"])}
518
+ # newline (and other control characters) resolve like any character in TMP;
519
+ # report how the primary font serves them
520
+ for u in (0x0A,):
521
+ r = fonts.lookup(primary, u)
522
+ coverage.setdefault("control", {})[f"U+{u:04X}"] = r[0] if r else None
523
+ return coverage, needed, runtime_units
524
+
525
+
526
+ def text_materials(ex: Exporter, fonts: FontSet, localized: list[dict], primaries: set, needed: dict,
527
+ runtime_units: dict) -> tuple[dict, dict]:
528
+ """The localized text materials (in text order), then the default material of every font asset that
529
+ serves a character or is a primary. -> (all materials, text materials)."""
530
+ materials: dict[str, dict] = {}
531
+ for loc in localized:
532
+ if loc["material"] not in materials:
533
+ materials[loc["material"]] = material_by_name(ex, fonts.fonts[loc["fontAsset"]], loc["material"])
534
+ text_mats = dict(materials)
535
+ for f in fonts.fonts.values():
536
+ if f.name in needed or f.name in runtime_units or f.name in primaries:
537
+ m = ex.material(f.material_obj)
538
+ materials.setdefault(m["material"], m)
539
+ return materials, text_mats
540
+
541
+
542
+ def export_fonts(ex: Exporter, fonts: FontSet, primaries: set, needed: dict, runtime_units: dict,
543
+ text_mats: dict, packer: TexelPacker, subset: str) -> dict:
544
+ """Font asset records with the character / glyph tables reduced to the `needed` baked characters (plus the
545
+ baked control characters) and the runtime characters generated from the source font; glyph texel blocks
546
+ go to `packer` (resolve them with resolve_font_blocks after packer.build())."""
547
+ font_out = {}
548
+ for f in fonts.fonts.values():
549
+ units = needed.get(f.name, set())
550
+ extra = runtime_units.get(f.name, set())
551
+ if f.name not in primaries and not units and not extra:
552
+ continue
553
+ mat = ex.material(f.material_obj)
554
+ gs = mat["floats"]["_GradientScale"]
555
+ aw, ah = f.tt["m_AtlasWidth"], f.tt["m_AtlasHeight"]
556
+ # the runtime replaces _TextureWidth/_TextureHeight with the packed size
557
+ # (texel-space math in the shader stays the same); that needs them to be
558
+ # the atlas size in the source material
559
+ if (mat["floats"]["_TextureWidth"], mat["floats"]["_TextureHeight"]) != (aw, ah):
560
+ raise RuntimeError(f"{f.name}: material texture size differs from the atlas size {aw}x{ah}")
561
+ # Texels a glyph quad can sample, around its glyph rect: the quad padding
562
+ # (padding + style padding, clamped to _GradientScale by TMP), the underlay
563
+ # sample offset (-_UnderlayOffset * _ScaleRatioC * _GradientScale texels,
564
+ # ScaleRatioC <= 1) and one texel for bilinear filtering.
565
+ off = max([abs(m["floats"].get(k, 0.0)) for m in text_mats.values()
566
+ if "UNDERLAY_ON" in m["keywords"] for k in ("_UnderlayOffsetX", "_UnderlayOffsetY")] + [0.0])
567
+ margin = math.ceil(gs) + math.ceil(off * gs) + 1
568
+ tt = f.tt
569
+ chars_out, glyphs_out = {}, {}
570
+ for u in sorted(units | {c for c in TMP_SYNTHESIZED if c in f.chars}):
571
+ c = f.chars[u]
572
+ chars_out[str(u)] = {"glyph": c["m_GlyphIndex"], "scale": c["m_Scale"], "elementType": c["m_ElementType"]}
573
+ g = f.glyphs[c["m_GlyphIndex"]]
574
+ gr = g["m_GlyphRect"]
575
+ rec = {"metrics": g["m_Metrics"], "rect": gr, "scale": g["m_Scale"], "atlasIndex": g["m_AtlasIndex"]}
576
+ if gr["m_Width"] > 0 and gr["m_Height"] > 0:
577
+ atlas = f.atlases[g["m_AtlasIndex"]]
578
+ if atlas is None:
579
+ raise RuntimeError(f"{f.name}: glyph {g['m_Index']} on a missing atlas page")
580
+ box = grow_box(gr["m_X"], gr["m_Y"], gr["m_X"] + gr["m_Width"], gr["m_Y"] + gr["m_Height"], margin)
581
+ rec["_block"] = packer.add(f"font_{f.name}", atlas, *box)
582
+ glyphs_out[str(g["m_Index"])] = rec
583
+ runtime_rec = None
584
+ if extra:
585
+ runtime_rec = runtime_glyphs(f, extra, units, margin, packer, chars_out, glyphs_out)
586
+ pair_records = tt["m_FontFeatureTable"]["m_GlyphPairAdjustmentRecords"]
587
+ font_out[f.name] = {
588
+ "faceInfo": tt["m_FaceInfo"], "atlasWidth": tt["m_AtlasWidth"], "atlasHeight": tt["m_AtlasHeight"],
589
+ "atlasPadding": tt["m_AtlasPadding"], "atlasRenderMode": tt["m_AtlasRenderMode"],
590
+ "atlasPopulationMode": tt["m_AtlasPopulationMode"],
591
+ "atlases": [a.read_typetree()["m_Name"] if a is not None else None for a in f.atlases],
592
+ "normalStyle": tt["normalStyle"], "normalSpacingOffset": tt["normalSpacingOffset"],
593
+ "boldStyle": tt["boldStyle"], "boldSpacing": tt["boldSpacing"], "italicStyle": tt["italicStyle"],
594
+ "tabSize": tt["tabSize"], "material": mat["material"],
595
+ "fallbacks": [fb.name for fb in fonts.fallbacks(f)],
596
+ "glyphPairAdjustmentRecords": len(pair_records),
597
+ "sourceFont": f.source_font, "characters": chars_out, "glyphs": glyphs_out,
598
+ "runtimeCharacters": sorted(extra),
599
+ "runtimeGlyphs": runtime_rec,
600
+ "subset": subset,
601
+ }
602
+ if pair_records:
603
+ font_out[f.name]["glyphPairAdjustments"] = pair_adjustments(
604
+ pair_records, {int(g) for g in glyphs_out})
605
+ return font_out
606
+
607
+
608
+ def pair_adjustments(records: list, glyphs: set) -> dict:
609
+ """The font asset's glyph pair adjustment lookup as TMP_FontAsset.InitializeGlyphPaidAdjustmentRecords-
610
+ LookupDictionary builds it (key = first glyph | second glyph << 16, the first record of a key
611
+ wins), reduced to the pairs whose two glyphs are exported. -> {key: {first, second, flags}} with the
612
+ value records {xPlacement, yPlacement, xAdvance, yAdvance} and m_FeatureLookupFlags."""
613
+ def value(r: dict) -> dict:
614
+ v = r["m_GlyphValueRecord"]
615
+ return {"xPlacement": v["m_XPlacement"], "yPlacement": v["m_YPlacement"],
616
+ "xAdvance": v["m_XAdvance"], "yAdvance": v["m_YAdvance"]}
617
+ out: dict[str, dict] = {}
618
+ seen: set[int] = set()
619
+ for r in records:
620
+ a, b = r["m_FirstAdjustmentRecord"], r["m_SecondAdjustmentRecord"]
621
+ key = a["m_GlyphIndex"] | (b["m_GlyphIndex"] << 16)
622
+ if key in seen:
623
+ continue
624
+ seen.add(key)
625
+ if a["m_GlyphIndex"] in glyphs and b["m_GlyphIndex"] in glyphs:
626
+ out[str(key)] = {"first": value(a), "second": value(b), "flags": r["m_FeatureLookupFlags"]}
627
+ return out
628
+
629
+
630
+ def resolve_font_blocks(font_out: dict) -> None:
631
+ """After TexelPacker.build(): glyph texel blocks -> {texture, dx, dy} (integer offset into the packed texture)."""
632
+ for fo in font_out.values():
633
+ for g in fo["glyphs"].values():
634
+ b = g.pop("_block", None)
635
+ if b is not None:
636
+ g["packed"] = {"texture": b["texture"], "dx": b["offset"][0], "dy": b["offset"][1]}