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.
- nnnotes/__init__.py +7 -0
- nnnotes/__main__.py +4 -0
- nnnotes/acb.py +190 -0
- nnnotes/addressables.py +413 -0
- nnnotes/adv.py +243 -0
- nnnotes/advcommand.py +29 -0
- nnnotes/advmedia.py +178 -0
- nnnotes/advscene.py +113 -0
- nnnotes/advui.py +1154 -0
- nnnotes/advvideo.py +684 -0
- nnnotes/atoms/__init__.py +151 -0
- nnnotes/atoms/astc.py +52 -0
- nnnotes/atoms/gltf.py +290 -0
- nnnotes/atoms/mesh.py +289 -0
- nnnotes/atoms/png.py +28 -0
- nnnotes/atoms/reader.py +269 -0
- nnnotes/atoms/sniff.py +198 -0
- nnnotes/atoms/sprite.py +236 -0
- nnnotes/atoms/texture.py +80 -0
- nnnotes/cache.py +289 -0
- nnnotes/catalog.py +373 -0
- nnnotes/catalogdb.py +457 -0
- nnnotes/census.py +276 -0
- nnnotes/cli.py +744 -0
- nnnotes/cli_assets.py +1539 -0
- nnnotes/config.py +238 -0
- nnnotes/contract.py +497 -0
- nnnotes/cri.py +371 -0
- nnnotes/crikey.py +71 -0
- nnnotes/crilips.py +265 -0
- nnnotes/cristages.py +558 -0
- nnnotes/deckdata.py +462 -0
- nnnotes/export.py +1462 -0
- nnnotes/gameapi.py +318 -0
- nnnotes/jsonio.py +98 -0
- nnnotes/languages.py +44 -0
- nnnotes/layout.py +596 -0
- nnnotes/link.py +418 -0
- nnnotes/live.py +115 -0
- nnnotes/live2d.py +235 -0
- nnnotes/liveaudio.py +331 -0
- nnnotes/livenotes.py +291 -0
- nnnotes/liveoptions.py +250 -0
- nnnotes/livescene.py +450 -0
- nnnotes/liveui.py +328 -0
- nnnotes/master.py +251 -0
- nnnotes/modelstages.py +424 -0
- nnnotes/motion.py +131 -0
- nnnotes/objexport.py +1564 -0
- nnnotes/orchestrate.py +630 -0
- nnnotes/plan.py +218 -0
- nnnotes/player.py +392 -0
- nnnotes/room.py +391 -0
- nnnotes/score.py +1139 -0
- nnnotes/shader.py +217 -0
- nnnotes/spot.py +201 -0
- nnnotes/stages.py +285 -0
- nnnotes/store.py +518 -0
- nnnotes/story.py +142 -0
- nnnotes/storyfonts.py +1258 -0
- nnnotes/storyhost.py +1190 -0
- nnnotes/storysite.py +662 -0
- nnnotes/storyvoices.py +102 -0
- nnnotes/textstyle.py +355 -0
- nnnotes/tmpfont.py +636 -0
- nnnotes/typetrees/6000.3.12f1.json +19 -0
- nnnotes/unity.py +368 -0
- nnnotes/viewrules.json +441 -0
- nnnotes/views.py +966 -0
- nnnotes/voices.py +1363 -0
- nnnotes/web.py +1624 -0
- nnnotes/webaudio.py +14 -0
- nnnotes/webmodel.py +397 -0
- nnnotes-0.1.0.dist-info/METADATA +200 -0
- nnnotes-0.1.0.dist-info/RECORD +79 -0
- nnnotes-0.1.0.dist-info/WHEEL +5 -0
- nnnotes-0.1.0.dist-info/entry_points.txt +2 -0
- nnnotes-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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]}
|