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/storyfonts.py ADDED
@@ -0,0 +1,1258 @@
1
+ """The TextMeshPro font data of a story's texts in one language -> <story>/ui/fonts.json, ui/fonts/*.png and
2
+ ui/languages.json (the site's per-language files, storysite.py; format in ournotes-player docs/story-data-format.md).
3
+
4
+ ui/fonts.json font assets reduced to the characters the episode shows in the language, their glyph pages
5
+ (ui/fonts/*.png), the text materials, their GLES3 keyword sets and the text binding of every
6
+ text node of ui/ui.json: its serialized TextMeshPro fields and the font asset, material and
7
+ line spacing it uses in the language (LocalizeText); with open fonts also `chatTexts` and
8
+ `dialogTexts`, the bindings of the chat window texts of ui.json `chatTexts` and of the text
9
+ nodes of its `dialogs`
10
+ ui/languages.json the language's LanguageMode, text field, line spacing and the font asset and line metrics of
11
+ each font role
12
+ ui/shaders/ the shader the text materials name (TextMeshPro/Mobile/Distance Field) is added to the UI's
13
+
14
+ Two sources give the same format:
15
+
16
+ open_fonts (default) glyphs generated from a font file per language: each text's localized game font asset
17
+ (LocalizeText) is replaced by an asset of the font file with the game asset's point size, padding,
18
+ style settings and render mode, holding the characters of the episode's text table, its title, the
19
+ static labels of the UI and the chat windows' status and run-time texts in that language plus
20
+ TextMeshPro's synthesized control characters and U+005F (UNDERLINE_CHARACTER). Face info and glyph
21
+ metrics come from the font file at that point size (FreeType, no hinting); the distance field is
22
+ tmpfont.RuntimeGlyphs' (the supersampled render modes rasterize at up to MAX_OVERSAMPLE times the size,
23
+ recorded in atlasRenderMode; the anti-aliased distance-field modes are generated as the 1X distance
24
+ field and recorded as SDF). Glyphs are shelf-packed, U+005F first (on the first page), then in code
25
+ point order of their first character into pages of PAGE_WIDTH texels, at most PAGE_MAX_HEIGHT high;
26
+ every page of an asset has the same size. The text materials are the game's localized materials with
27
+ the page as _MainTex and the page size as _TextureWidth / _TextureHeight. The game asset's fallback
28
+ chain is mirrored (open_font_set): each game fallback asset stands for an asset of the font file of
29
+ the language LocalizeManager lists it for, which holds the characters the assets before it in the
30
+ chain lack. No glyph or atlas texel of the game is read.
31
+ game_fonts the game's TMP font assets (advui.extract with fonts="game", tmpfont.export_fonts): the same records
32
+ moved from its ui.json into fonts.json, the glyph pages into ui/fonts/.
33
+
34
+ Emoji: every text a Fwk.UI.UIText drives has LocalizeManager's emoji sprite asset (UIText.Awake), which draws the
35
+ characters its font assets lack and the <sprite name> tags the emoji search writes for emoji sequences. With open
36
+ fonts the sprite asset holds the sprites the episode's texts can draw (sprite_needs), each image drawn from the emoji
37
+ font (EmojiFont; none: empty glyphs) at the game sprite's size, and the open font assets of those texts leave out the
38
+ code points a sprite draws; with game fonts it is the game's asset with its sheet. Without a sprite to draw, fonts.json
39
+ has no sprite asset.
40
+
41
+ Same inputs (font file, episode, game data) and library versions give the same bytes; generated glyph cells are kept
42
+ in the process cache (disk layer when the build configures one) by font file, sizes and glyph.
43
+ """
44
+ from __future__ import annotations
45
+
46
+ import copy
47
+ import hashlib
48
+ import json
49
+ import math
50
+ import shutil
51
+ import unicodedata
52
+ from pathlib import Path
53
+
54
+ import numpy as np
55
+
56
+ from . import adv, advui, cache, jsonio, languages, master, textstyle, tmpfont
57
+ from . import shader as shader_mod
58
+ from .config import ConfigError
59
+ from .export import Exporter, _safe, packed_png, texture_png
60
+
61
+ FONTS_FORMAT = "ournotes.story-fonts/1"
62
+ LANGUAGE_FORMAT = "ournotes.story-language/1"
63
+ FONTS_DOC = "fonts.json"
64
+ LANGUAGE_DOC = "languages.json"
65
+ PAGES_DIR = "fonts"
66
+ PAGE_WIDTH = 2048
67
+ PAGE_MAX_HEIGHT = 4096
68
+ MAX_OVERSAMPLE = 8
69
+ SOURCES = ("open", "game")
70
+ TEXT_SHADER_PREFIX = "TextMeshPro/"
71
+ # UnityEngine.TextCore.LowLevel.GlyphRasterModes flags of the supersampled render modes -> raster pixels per texel
72
+ OVERSAMPLE_FLAGS = {tmpfont.RASTER_MODE["8X"]: 8, tmpfont.RASTER_MODE["16X"]: 16, tmpfont.RASTER_MODE["32X"]: 32}
73
+ ELEMENT_CHARACTER = 1 # TMP TextElementType.Character
74
+ UNDERLINE_CHARACTER = advui.UNDERLINE_CHARACTER # kept on the first page of every open font asset
75
+ # ruby settings of the ruby text classes (RubyTextMeshProUGUI, RubyEmojiTextMeshProUGUI) and of Fwk.UI.UIRubyText
76
+ RUBY_CLASSES = ("RubyTextMeshProUGUI", "RubyEmojiTextMeshProUGUI")
77
+ RUBY_FIELDS = ("_rubyVerticalOffset", "_rubyScale", "_rubyLineHeight", "_rubyShowType", "_rubyMargin")
78
+ UI_RUBY_CLASS = "UIRubyText"
79
+ UI_RUBY_FIELDS = ("_rubyMarginTop",)
80
+ KOREAN_ADJUST_CLASS = "LocalizeKoreanAdjust" # the font style a text gets in Korean (LocalizeKoreanAdjust.Apply)
81
+ UI_TEXT_CLASSES = ("UIText", "UIRubyText") # Fwk.UI.UIText and its subclass
82
+ GLYPHS = cache.bucket("storyglyphs", disk=True)
83
+ FACE_KEYS = ("m_FaceIndex", "m_FamilyName", "m_StyleName", "m_PointSize", "m_Scale", "m_UnitsPerEM", "m_LineHeight",
84
+ "m_AscentLine", "m_CapLine", "m_MeanLine", "m_Baseline", "m_DescentLine", "m_SuperscriptOffset",
85
+ "m_SuperscriptSize", "m_SubscriptOffset", "m_SubscriptSize", "m_UnderlineOffset", "m_UnderlineThickness",
86
+ "m_StrikethroughOffset", "m_StrikethroughThickness", "m_TabWidth")
87
+ STYLE_KEYS = ("normalStyle", "normalSpacingOffset", "boldStyle", "boldSpacing", "italicStyle", "tabSize")
88
+ # OpenType name table ids
89
+ NAME_FAMILY, NAME_STYLE, NAME_VERSION, NAME_LICENSE, NAME_LICENSE_URL = 1, 2, 5, 13, 14
90
+ NAME_TYPO_FAMILY, NAME_TYPO_STYLE = 16, 17
91
+
92
+
93
+ def _dump(obj) -> bytes:
94
+ return jsonio.dumps(obj, ensure_ascii=False, indent=1, sort_keys=True).encode("utf-8") + b"\n"
95
+
96
+
97
+ def _salt() -> str:
98
+ return cache.key(cache.source_salt(__file__), cache.source_salt(tmpfont.__file__, "freetype-py", "scipy", "numpy"))
99
+
100
+
101
+ # ---------------------------------------------------------------- font files
102
+ class FontFile:
103
+ """A font file of one's own (OpenType / TrueType; the first face of a collection): its bytes, identity and names."""
104
+
105
+ def __init__(self, path, face_index: int = 0):
106
+ from fontTools.ttLib import TTFont
107
+ self.path = Path(path)
108
+ self.data = self.path.read_bytes()
109
+ self.face_index = face_index
110
+ self.sha256 = hashlib.sha256(self.data).hexdigest()
111
+ import io
112
+ ft = TTFont(io.BytesIO(self.data), fontNumber=face_index, lazy=True)
113
+ names = ft["name"]
114
+
115
+ def name(*ids) -> str:
116
+ for i in ids:
117
+ rec = names.getDebugName(i)
118
+ if rec:
119
+ return rec.strip()
120
+ return ""
121
+ self.family = name(NAME_TYPO_FAMILY, NAME_FAMILY)
122
+ self.style = name(NAME_TYPO_STYLE, NAME_STYLE)
123
+ if not self.family:
124
+ raise ValueError(f"font {self.path.name}: no family name")
125
+ self.version = name(NAME_VERSION)
126
+ self.license = name(NAME_LICENSE_URL, NAME_LICENSE)
127
+ self._face = None
128
+
129
+ def maps(self, u: int) -> bool:
130
+ """Whether the font maps the code point (FreeType's character map, as open_asset reads it)."""
131
+ if self._face is None:
132
+ import freetype
133
+ import io
134
+ self._face = freetype.Face(io.BytesIO(self.data), self.face_index)
135
+ return self._face.get_char_index(u) != 0
136
+
137
+ @property
138
+ def asset_name(self) -> str:
139
+ return f"{self.family} {self.style} SDF".replace(" ", " ")
140
+
141
+ def source(self, generator: dict) -> dict:
142
+ return {"family": self.family, "style": self.style, "version": self.version, "license": self.license,
143
+ "file": self.path.name, "faceIndex": self.face_index, "bytes": len(self.data), "sha256": self.sha256,
144
+ "generator": generator}
145
+
146
+
147
+ def oversample_of(render_mode: int) -> tuple[int, int]:
148
+ """(raster pixels per texel, the GlyphRenderMode recorded) for a game font asset's render mode: 1X modes as
149
+ they are, the supersampled ones at most MAX_OVERSAMPLE (the recorded mode names the factor used). The
150
+ anti-aliased distance-field modes (SDFAA, SDFAA_HINTED: a distance field from the 8-bit coverage raster) are
151
+ generated as the 1X distance field of the monochrome raster and recorded as SDF; TextMeshPro draws both with
152
+ the same distance-field shader and spread."""
153
+ mode = tmpfont.RASTER_MODE
154
+ if render_mode & mode["SDFAA"] and not render_mode & (mode["SDF"] | mode["BITMAP"]):
155
+ return 1, mode["1X"] | mode["SDF"] | mode["NO_HINTING"] | mode["MONO"]
156
+ if not render_mode & mode["SDF"]:
157
+ raise NotImplementedError(f"render mode {render_mode}: not a distance-field mode")
158
+ for flag, k in OVERSAMPLE_FLAGS.items():
159
+ if render_mode & flag:
160
+ if k <= MAX_OVERSAMPLE:
161
+ return k, render_mode
162
+ capped = next(f for f, v in OVERSAMPLE_FLAGS.items() if v == MAX_OVERSAMPLE)
163
+ return MAX_OVERSAMPLE, (render_mode & ~flag) | capped
164
+ return 1, render_mode
165
+
166
+
167
+ def face_info(gen: tmpfont.RuntimeGlyphs, font: FontFile, point_size: int) -> dict:
168
+ """TMP face info (UnityEngine.TextCore.FaceInfo) of `font` at `point_size` px per em, as the game's font assets
169
+ that embed their source font have it: line height, ascent and descent from the face's design metrics (unrounded),
170
+ cap and mean line at the top of the H / x glyph (unhinted outline) rounded up to whole px (0 without the glyph),
171
+ super- / subscript at the ascent / descent line with size 0.5, underline as FreeType gives it (the post table's
172
+ position minus half its thickness), strikethrough at mean line / 2.5 with the underline thickness, tab width the
173
+ space advance rounded to whole px. Values are float32, the type of the serialized fields."""
174
+ face = gen.face
175
+ s = point_size / face.units_per_EM
176
+
177
+ def f32(v: float) -> float:
178
+ return float(np.float32(v))
179
+
180
+ def top(ch: str) -> float:
181
+ gi = face.get_char_index(ord(ch))
182
+ return float(math.ceil(gen.metrics(gi)["m_HorizontalBearingY"])) if gi else 0.0
183
+ cap, mean = top("H"), top("x")
184
+ space = face.get_char_index(0x20)
185
+ underline = f32(face.underline_thickness * s)
186
+ return {
187
+ "m_FaceIndex": font.face_index, "m_FamilyName": font.family, "m_StyleName": font.style,
188
+ "m_PointSize": float(point_size), "m_Scale": 1.0, "m_UnitsPerEM": face.units_per_EM,
189
+ "m_LineHeight": f32(face.height * s), "m_AscentLine": f32(face.ascender * s), "m_CapLine": cap,
190
+ "m_MeanLine": mean, "m_Baseline": 0.0, "m_DescentLine": f32(face.descender * s),
191
+ "m_SuperscriptOffset": f32(face.ascender * s), "m_SuperscriptSize": 0.5,
192
+ "m_SubscriptOffset": f32(face.descender * s), "m_SubscriptSize": 0.5,
193
+ "m_UnderlineOffset": f32(face.underline_position * s), "m_UnderlineThickness": underline,
194
+ "m_StrikethroughOffset": f32(mean / 2.5), "m_StrikethroughThickness": underline,
195
+ "m_TabWidth": float(round(gen.metrics(space)["m_HorizontalAdvance"])) if space else 0.0,
196
+ }
197
+
198
+
199
+ def glyph_cell(gen: tmpfont.RuntimeGlyphs, font: FontFile, gi: int, margin: int) -> tuple[dict, tuple, np.ndarray]:
200
+ """gen.cell(gi, margin), kept in the GLYPHS cache."""
201
+ k = GLYPHS.key(_salt(), font.sha256, font.face_index, gen.point_size, gen.pad, gen.oversample, margin, gi)
202
+ hit = GLYPHS.get(k)
203
+ if hit is not None:
204
+ meta, (blob,) = cache.unpack(hit)
205
+ m, box = json.loads(meta)
206
+ return m, tuple(box), np.frombuffer(blob, np.uint8).reshape(box[3] + 2 * margin, box[2] + 2 * margin)
207
+ m, box, a = gen.cell(gi, margin)
208
+ GLYPHS.put(k, cache.pack(json.dumps([m, list(box)]).encode("utf-8"), [a.tobytes()]))
209
+ return m, box, a
210
+
211
+
212
+ def pack_cells(cells: list[tuple[int, int, int]]) -> tuple[dict[int, tuple[int, int, int]], int, int]:
213
+ """Shelf packing of cells [(glyph, width, height)] in the given order into pages PAGE_WIDTH wide (wider when a
214
+ cell is), a new page when a shelf would pass PAGE_MAX_HEIGHT -> ({glyph: (page, x, y)}, width, height), every
215
+ page of the same size."""
216
+ width = max([PAGE_WIDTH] + [w for _, w, _ in cells])
217
+ out, page, x, y, shelf, height = {}, 0, 0, 0, 0, 1
218
+ for g, w, h in cells:
219
+ if x + w > width:
220
+ x, y, shelf = 0, y + shelf, 0
221
+ if y + h > PAGE_MAX_HEIGHT and y > 0:
222
+ page, x, y, shelf = page + 1, 0, 0, 0
223
+ out[g] = (page, x, y)
224
+ x += w
225
+ shelf = max(shelf, h)
226
+ height = max(height, y + shelf)
227
+ return out, width, height
228
+
229
+
230
+ # ---------------------------------------------------------------- texts
231
+ def _ui_prefab_nodes(ex: Exporter, ui_doc: dict) -> tuple[dict[str, dict], dict[str, dict]]:
232
+ """({path: node} of UIAdvWidget's prefab, {path: node} of the talk windows of ui.json under TalkView)."""
233
+ widget = {n["path"]: n for n in ex.prefab(advui.WIDGET_KEY)["nodes"]}
234
+ names = [rec["name"] for rec in ui_doc["nodes"] if rec["path"] == f"{advui.WINDOW_PARENT}/{rec['name']}"]
235
+ return widget, {n["path"]: n for n in advui.window_nodes(ex, names)}
236
+
237
+
238
+ def ui_text_targets(nodes, root: str = "") -> set[str]:
239
+ """The paths (`root` + the prefab path) of the texts the Fwk.UI.UIText components (and its subclass UIRubyText) of
240
+ the prefab `nodes` drive (_targetText, on any node): UIText.Awake registers OnEmojiSpriteAssetLoaded with
241
+ LocalizeManager, which sets the text's sprite asset (TMP_Text.spriteAsset) to LocalizeManager's emoji sprite
242
+ asset, over any serialized one (the asset is loaded at the title, before any story). A UIText without a target
243
+ text drives none."""
244
+ out = set()
245
+ for n in nodes:
246
+ for c in n["components"]:
247
+ if c.get("class") in UI_TEXT_CLASSES:
248
+ target = (c.get("_targetText") or {}).get("gameObject")
249
+ if target is not None:
250
+ out.add(root + target)
251
+ return out
252
+
253
+
254
+ def story_ui_text_targets(ex: Exporter, ui_doc: dict) -> set[str]:
255
+ """ui_text_targets of UIAdvWidget and of the talk windows of ui.json (paths under TalkView)."""
256
+ names = [rec["name"] for rec in ui_doc["nodes"] if rec["path"] == f"{advui.WINDOW_PARENT}/{rec['name']}"]
257
+ out = ui_text_targets(ex.prefab(advui.WIDGET_KEY)["nodes"])
258
+ for name in names:
259
+ out |= ui_text_targets(ex.prefab(advui.window_key(ex.cat, name))["nodes"], f"{advui.WINDOW_PARENT}/")
260
+ return out
261
+
262
+
263
+ def sprite_binding(path: str, node: list[dict], driven: bool, sprite: str | None) -> dict:
264
+ """The sprite settings of a text binding: for a text a UIText drives (`driven`), `spriteAsset` = `sprite` (the
265
+ emoji sprite asset's name in fonts.json) and the text's serialized `m_tintAllSprites`; {} for any other text, which
266
+ keeps its serialized sprite asset (only none is implemented)."""
267
+ comps = [c for c in node if c.get("class") in advui.TMP_CLASSES]
268
+ if len(comps) != 1:
269
+ raise RuntimeError(f"{path}: {len(comps)} text components")
270
+ c = comps[0]
271
+ if not driven:
272
+ if c.get("m_spriteAsset"):
273
+ raise NotImplementedError(f"{path}: a text with its own sprite asset")
274
+ return {}
275
+ if sprite is None:
276
+ raise RuntimeError(f"{path}: a UIText text without the emoji sprite asset")
277
+ return {"spriteAsset": sprite, "m_tintAllSprites": c["m_tintAllSprites"]}
278
+
279
+
280
+ def text_components(ex: Exporter, ui_doc: dict) -> dict[str, list[dict]]:
281
+ """{node path: the node's components} of every text node of the story UI (ui.json nodes with a text record), from
282
+ the prefabs advui assembles (UIAdvWidget with the talk windows of ui.json, the children of TalkView, under it)."""
283
+ widget, window = _ui_prefab_nodes(ex, ui_doc)
284
+ out = {}
285
+ for rec in ui_doc["nodes"]:
286
+ if "textStyle" not in rec:
287
+ continue
288
+ path = rec["path"]
289
+ hits = [n for n in (widget.get(path), window.get(path)) if n is not None]
290
+ if len(hits) != 1:
291
+ raise RuntimeError(f"{path}: {len(hits)} prefab nodes")
292
+ out[path] = hits[0]["components"]
293
+ return out
294
+
295
+
296
+ def text_extras(path: str, comps: list[dict]) -> dict:
297
+ """The settings of a text node's other text components, each only when the node has that component: `ruby` (the
298
+ serialized RUBY_FIELDS of a ruby text component), `uiRubyText` (UI_RUBY_FIELDS of its UIRubyText component) and
299
+ `localizeKoreanAdjust` (`_koreanFontStyle` and `m_Enabled` of its LocalizeKoreanAdjust component)."""
300
+ out = {}
301
+ for c in comps:
302
+ if c.get("class") in RUBY_CLASSES:
303
+ missing = [k for k in RUBY_FIELDS if k not in c]
304
+ if missing:
305
+ raise RuntimeError(f"{path}: ruby text without {missing}")
306
+ out["ruby"] = {k: c[k] for k in RUBY_FIELDS}
307
+ elif c.get("class") == UI_RUBY_CLASS:
308
+ out["uiRubyText"] = {k: c[k] for k in UI_RUBY_FIELDS}
309
+ elif c.get("class") == KOREAN_ADJUST_CLASS:
310
+ out["localizeKoreanAdjust"] = {"_koreanFontStyle": c["_koreanFontStyle"], "m_Enabled": bool(c["m_Enabled"])}
311
+ return out
312
+
313
+
314
+ def _binding(path: str, node: list[dict], swap: dict, lang: dict, mode: int, driven: bool = False,
315
+ sprite: str | None = None) -> dict:
316
+ """The text binding of a text node (its components `node`): the serialized TextMeshPro fields of its text
317
+ component, as advui writes them with fonts="game", the settings of its other text components (text_extras),
318
+ `localized` = textstyle.localize_text (LocalizeText) in the language `mode` and its sprite settings
319
+ (sprite_binding: `driven` by a UIText, `sprite` the emoji sprite asset's name)."""
320
+ comps = [c for c in node if c.get("class") in advui.TMP_CLASSES]
321
+ if len(comps) != 1:
322
+ raise RuntimeError(f"{path}: {len(comps)} text components")
323
+ c = comps[0]
324
+ t = {k: c[k] for k in advui.TMP_FIELDS if k in c}
325
+ t.update({"class": c["class"], "enabled": c["m_Enabled"], "fontAsset": c["m_fontAsset"]["name"],
326
+ "material": c["m_sharedMaterial"]["material"]})
327
+ t.update(text_extras(path, node))
328
+ t["localized"] = textstyle.localize_text(path, t["fontAsset"], t["material"], swap, lang, mode)
329
+ t.update(sprite_binding(path, node, driven, sprite))
330
+ return t
331
+
332
+
333
+ def text_bindings(ex: Exporter, ui_doc: dict, lang: dict, mode: int, sprite: str | None = None) -> dict[str, dict]:
334
+ """{node path: text binding (_binding)} of every text node of the story UI (text_components); `sprite` = the
335
+ emoji sprite asset's name for the texts a UIText drives (story_ui_text_targets)."""
336
+ swap = textstyle.font_swap(lang, mode)
337
+ driven = story_ui_text_targets(ex, ui_doc)
338
+ return {path: _binding(path, node, swap, lang, mode, path in driven, sprite)
339
+ for path, node in text_components(ex, ui_doc).items()}
340
+
341
+
342
+ def dialog_bindings(ex: Exporter, ui_doc: dict, lang: dict, mode: int,
343
+ sprite: str | None = None) -> dict[str, dict[str, dict]]:
344
+ """{dialog: {node path: text binding (_binding)}} of the text nodes of the dialogs of ui.json (`dialogs`, each
345
+ from its prefab `key`); `sprite` as text_bindings."""
346
+ swap = textstyle.font_swap(lang, mode)
347
+ out: dict[str, dict[str, dict]] = {}
348
+ for name, d in sorted((ui_doc.get("dialogs") or {}).items()):
349
+ nodes = ex.prefab(d["key"])["nodes"]
350
+ prefab, driven = {n["path"]: n["components"] for n in nodes}, ui_text_targets(nodes)
351
+ for rec in d["nodes"]:
352
+ if "textStyle" in rec:
353
+ out.setdefault(name, {})[rec["path"]] = _binding(rec["path"], prefab[rec["path"]], swap, lang, mode,
354
+ rec["path"] in driven, sprite)
355
+ return out
356
+
357
+
358
+ def chat_bindings(ex: Exporter, episode: dict, lang: dict, mode: int,
359
+ sprite: str | None = None) -> dict[str, dict[str, dict]]:
360
+ """{chat window: {node path: text binding}} of the TMP texts of the episode's chat windows (advui.chat_text_nodes):
361
+ the fields text_bindings gives, `localized` from advui.chat_localize for a text with LocalizeText enabled, else
362
+ the serialized font asset, material and line spacing; `sprite` as text_bindings."""
363
+ out: dict[str, dict[str, dict]] = {}
364
+ nodes, _ = advui.chat_text_nodes(ex, episode)
365
+ driven = set().union(*(ui_text_targets(ex.prefab(address)["nodes"])
366
+ for address in advui.chat_windows(episode).values()))
367
+ for window, node, c, loc in nodes:
368
+ t = {k: c[k] for k in advui.TMP_FIELDS if k in c}
369
+ t.update({"class": c["class"], "enabled": c["m_Enabled"], "fontAsset": c["m_fontAsset"]["name"],
370
+ "material": c["m_sharedMaterial"]["material"]})
371
+ t.update(text_extras(node["path"], node["components"]))
372
+ if loc and loc["m_Enabled"] and loc["_localizeEnabled"]:
373
+ t["localized"] = advui.chat_localize(t["fontAsset"], t["material"], lang, mode)
374
+ else:
375
+ t["localized"] = {"fontAsset": t["fontAsset"], "material": t["material"], "lineSpacing": c["m_lineSpacing"]}
376
+ t.update(sprite_binding(node["path"], node["components"], node["path"] in driven, sprite))
377
+ out.setdefault(window, {})[node["path"]] = t
378
+ return out
379
+
380
+
381
+ # the IAdvFrameTextReceiver implementations (AdvSystem.Asset.UI): a frame with one gets its texts at run time
382
+ FRAME_TEXT_RECEIVERS = ("AdvSlanderCommentFrame",)
383
+ FRAME_PREFIX = adv.RESOURCE_PREFIX["Frame"][1]
384
+
385
+
386
+ def frame_prefabs(ex: Exporter, episode: dict) -> dict[str, list[dict]]:
387
+ """{frame name (its address without the Frame prefix, the key of story/frames.json): its prefab node list} of the
388
+ episode's Frame resources in the catalog."""
389
+ return {r["address"][len(FRAME_PREFIX):]: ex.prefab(r["address"])["nodes"] for r in episode["resources"]
390
+ if r["kind"] == "frame" and r.get("present", True)}
391
+
392
+
393
+ def frame_bindings(frames: dict[str, list[dict]], lang: dict, mode: int,
394
+ sprite: str | None = None) -> dict[str, dict[str, dict]]:
395
+ """{frame name: {node path: text binding}} of the TMP texts of the episode's frames (`frames`: frame_prefabs), as
396
+ chat_bindings: `localized` from LocalizeText (advui.chat_localize) when it is enabled, else the serialized font
397
+ asset, material and line spacing; `sprite` as text_bindings (the texts a UIText drives)."""
398
+ out: dict[str, dict[str, dict]] = {}
399
+ for name, nodes in frames.items():
400
+ driven = ui_text_targets(nodes)
401
+ for node in nodes:
402
+ comps = [c for c in node["components"] if c.get("class") in advui.TMP_CLASSES]
403
+ if not comps:
404
+ continue
405
+ c = comps[0]
406
+ loc = next((x for x in node["components"] if x.get("class") == "LocalizeText"), None)
407
+ t = {k: c[k] for k in advui.TMP_FIELDS if k in c}
408
+ t.update({"class": c["class"], "enabled": c["m_Enabled"], "fontAsset": c["m_fontAsset"]["name"],
409
+ "material": c["m_sharedMaterial"]["material"]})
410
+ t.update(text_extras(node["path"], node["components"]))
411
+ if loc and loc["m_Enabled"] and loc["_localizeEnabled"]:
412
+ t["localized"] = advui.chat_localize(t["fontAsset"], t["material"], lang, mode)
413
+ else:
414
+ t["localized"] = {"fontAsset": t["fontAsset"], "material": t["material"],
415
+ "lineSpacing": c["m_lineSpacing"]}
416
+ t.update(sprite_binding(node["path"], node["components"], node["path"] in driven, sprite))
417
+ out.setdefault(name, {})[node["path"]] = t
418
+ return out
419
+
420
+
421
+ def frame_texts(frames: dict[str, list[dict]], episode: dict, field: str) -> list[str]:
422
+ """The texts the episode's frames (`frames`: frame_prefabs) show: the serialized texts of their text nodes, and
423
+ the texts AdvFrameCommand.SetFrameTexts gives the frames with an IAdvFrameTextReceiver (FRAME_TEXT_RECEIVERS): per
424
+ Frame row with TargetTextIDs, the localized text of each id (the episode's text table in the text field `field`;
425
+ an unknown id gives ""), in the form AdvSlanderCommentTextHelper shows it (NFC; "@" before the user id, line
426
+ feeds), plus "@" and a line feed when there is any."""
427
+ receivers = {name for name, nodes in frames.items()
428
+ if any(c.get("class") in FRAME_TEXT_RECEIVERS for n in nodes for c in n["components"])}
429
+ serialized = [c["m_text"] for nodes in frames.values() for n in nodes for c in n["components"]
430
+ if c.get("class") in advui.TMP_CLASSES and isinstance(c.get("m_text"), str) and c["m_text"]]
431
+ out = []
432
+ for c in episode["commands"]:
433
+ if c["cmd"] != "Frame" or c.get("IgnoreData") or not c.get("TargetTextIDs"):
434
+ continue
435
+ if (c.get("TargetAssetName") or "").strip() not in receivers:
436
+ continue
437
+ for i in c["TargetTextIDs"]:
438
+ row = episode["text"].get(i)
439
+ if row is not None and isinstance(row.get(field), str):
440
+ out.append(unicodedata.normalize("NFC", row[field]))
441
+ return serialized + (out + ["@\n"] if out else out)
442
+
443
+
444
+ def shown_texts(episode: dict, ui_doc: dict, master_dir: Path | None, field: str) -> list[str]:
445
+ """The texts the episode shows in the text field `field`: every text of its text table (lines, speaker names,
446
+ frame and choice texts), its title, the static labels of the UI (the MasterText rows of the text keys of the text
447
+ nodes and of the dialogs' text nodes, the `masterIdTexts` of ui.json) and of the chat windows
448
+ (`chatStatusTexts`), the serialized texts of the chat windows' text nodes (`chatTexts`, shown until the player
449
+ sets them) and the texts the chat windows format at run time (advui.chat_runtime_texts), markup included."""
450
+ texts = [row.get(field) for row in episode["text"].values()]
451
+ texts.append((episode.get("title") or {}).get(field))
452
+ texts += [t["text"] for t in (ui_doc.get("masterIdTexts") or {}).values()]
453
+ texts += list((ui_doc.get("chatStatusTexts") or {}).values()) + advui.chat_runtime_texts(episode)
454
+ texts += [rec["textStyle"].get("text") for w in (ui_doc.get("chatTexts") or {}).values() for rec in w.values()]
455
+ nodes = list(ui_doc["nodes"]) + [n for d in (ui_doc.get("dialogs") or {}).values() for n in d["nodes"]]
456
+ keys = {rec["textStyle"]["textKey"] for rec in nodes if "textStyle" in rec and rec["textStyle"].get("textKey")}
457
+ if keys:
458
+ if master_dir is None:
459
+ raise RuntimeError(f"UI text keys {sorted(keys)} need the master data")
460
+ rows = {r["_id"]: r for r in master.table(master_dir, "MasterText") if r["_id"] in keys}
461
+ missing = sorted(keys - set(rows))
462
+ if missing:
463
+ raise KeyError(f"UI text keys not in MasterText: {missing}")
464
+ texts += [rows[k].get(f"_{field}") for k in sorted(keys)]
465
+ return [s for s in texts if isinstance(s, str)]
466
+
467
+
468
+ def shown_characters(episode: dict, ui_doc: dict, master_dir: Path | None, field: str) -> list[int]:
469
+ """The code points of shown_texts, sorted."""
470
+ return sorted({ord(ch) for s in shown_texts(episode, ui_doc, master_dir, field) for ch in s})
471
+
472
+
473
+ def _material_textures(m: dict) -> None:
474
+ """Texture references of a material as advui writes them: {name, width, height, format}."""
475
+ for v in m["textures"].values():
476
+ t = v["texture"]
477
+ if t is not None:
478
+ v["texture"] = {"name": t["name"], "width": t["width"], "height": t["height"], "format": t.get("format")}
479
+
480
+
481
+ def text_shaders(ex: Exporter, ui_dir: Path, materials: dict) -> dict[str, list[str]]:
482
+ """Add the shaders the text `materials` name to ui/shaders (index kept in name order) and return each
483
+ material's GLES3 keyword set (its keywords that the shader's GLES3 variants use; a vertex program with exactly
484
+ those must exist)."""
485
+ sdir = Path(ui_dir) / "shaders"
486
+ index = json.loads((sdir / "shaders.json").read_text(encoding="utf-8"))
487
+ have = {r["name"] for r in index}
488
+ for name in sorted({m["shader"]["shader"] for m in materials.values()}):
489
+ if name in have:
490
+ continue
491
+ if name not in ex.shaders:
492
+ raise RuntimeError(f"shader {name} not in the loaded bundles")
493
+ shader_mod.dump_objects([ex.shaders[name]], sdir, ex.shaders[name].assets_file.name, index)
494
+ index.sort(key=lambda r: r["name"])
495
+ shader_mod.write_index(index, sdir)
496
+ recs = {r["name"]: r for r in index}
497
+ out = {}
498
+ for name, m in sorted(materials.items()):
499
+ rec = recs[m["shader"]["shader"]]
500
+ gles = [v for v in rec["variants"] if v["platform"] == "gles3" and v["type"] == "GLES3"]
501
+ known = {k for v in gles for k in v["keywords"]}
502
+ want = sorted(k for k in m["keywords"] if k in known)
503
+ if not any(sorted(v["keywords"]) == want and v["stage"] == "vertex" for v in gles):
504
+ raise RuntimeError(f"{rec['name']}: no GLES3 variant for material {name} keywords {want}")
505
+ out[name] = want
506
+ return out
507
+
508
+
509
+ def language_doc(language: str, source: str, fonts: dict, texts: dict, lang: dict) -> dict:
510
+ """ui/languages.json of `language`: LanguageMode, text field, line spacing and per font role the font asset
511
+ the role's texts use with its line metrics (textstyle.face_metrics)."""
512
+ mode = languages.mode(language)
513
+ roles: dict[str, dict] = {}
514
+ for path, t in sorted(texts.items()):
515
+ role = textstyle.font_role(t["fontAsset"], lang)
516
+ fa = t["localized"]["fontAsset"]
517
+ if roles.get(role, {}).get("fontAsset", fa) != fa:
518
+ raise NotImplementedError(f"font role {role} uses two font assets ({roles[role]['fontAsset']}, {fa})")
519
+ f = fonts[fa]
520
+ roles[role] = {"fontAsset": fa, **textstyle.face_metrics(f["faceInfo"], {k: f[k] for k in
521
+ ("normalSpacingOffset",
522
+ "boldSpacing")})}
523
+ return {"format": LANGUAGE_FORMAT, "language": language, "mode": mode, "field": languages.column(language)[1:],
524
+ "lineSpacing": textstyle.LANGUAGE_LINE_SPACING[mode], "fonts": source, "roles": roles}
525
+
526
+
527
+ def _write(ui_dir: Path, fonts_doc: dict, lang_doc: dict) -> None:
528
+ (Path(ui_dir) / FONTS_DOC).write_bytes(_dump(fonts_doc))
529
+ (Path(ui_dir) / LANGUAGE_DOC).write_bytes(_dump(lang_doc))
530
+
531
+
532
+ def _exporter(cat, player, work: Path) -> Exporter:
533
+ return Exporter(cat, Path(work), player=player, textures="deferred",
534
+ stub_assets=("TMP_FontAsset", "TMP_SpriteAsset", "TMP_StyleSheet"))
535
+
536
+
537
+ # ---------------------------------------------------------------- open fonts
538
+ PAGE_SETTINGS = {"m_FilterMode": 1, "m_Aniso": 1, "m_MipBias": 0.0, "m_WrapU": 1, "m_WrapV": 1, "m_WrapW": 1}
539
+ SUBSET = ("characters of the episode's text table, title and UI labels in this language, the synthesized "
540
+ "control characters and U+005F")
541
+
542
+
543
+ def _own_material(m: dict, name: str, page: str, width: int, height: int) -> dict:
544
+ """A game text material for an open font asset: named `name`, sampling `page` (width x height) as _MainTex,
545
+ _TextureWidth / _TextureHeight the page size; every other value as in the game."""
546
+ m = copy.deepcopy(m)
547
+ _material_textures(m)
548
+ m["material"] = name
549
+ tex = m["textures"].get("_MainTex") or {}
550
+ m["textures"]["_MainTex"] = {"texture": {"name": page, "width": width, "height": height,
551
+ "format": (tex.get("texture") or {}).get("format")},
552
+ "scale": tex.get("scale", {"x": 1.0, "y": 1.0}),
553
+ "offset": tex.get("offset", {"x": 0.0, "y": 0.0})}
554
+ m["floats"]["_TextureWidth"], m["floats"]["_TextureHeight"] = float(width), float(height)
555
+ return m
556
+
557
+
558
+ def open_asset(font: FontFile, name: str, chars, game: dict, text_materials: dict[str, dict],
559
+ underline: bool = True) -> dict:
560
+ """An open font asset of `font` named `name` for the code points `chars`, in place of the game font asset
561
+ `game` = {pointSize, padding, renderMode, style {STYLE_KEYS}, material (its default material record)} whose texts
562
+ use `text_materials` ({name: game material record}); with `underline` (the game asset itself has U+005F: its
563
+ underline and highlight glyph) also U+005F. -> {font (the asset record), textures {page: descriptor},
564
+ pages [(page, RGBA array bottom row first)], materials {name: record}, renames {game material: open material},
565
+ missing (code points the font does not map)}."""
566
+ pad, point = game["padding"], game["pointSize"]
567
+ gs = game["material"]["floats"]["_GradientScale"]
568
+ if gs != pad + 1:
569
+ raise NotImplementedError(f"{name}: _GradientScale {gs} is not padding + 1")
570
+ k, render_mode = oversample_of(game["renderMode"])
571
+ gen = tmpfont.RuntimeGlyphs.from_font_file(font.data, font.face_index, point, pad, k)
572
+ off = max([abs(m["floats"].get(p, 0.0)) for m in text_materials.values() if "UNDERLAY_ON" in m["keywords"]
573
+ for p in ("_UnderlayOffsetX", "_UnderlayOffsetY")] + [0.0])
574
+ margin = math.ceil(gs) + math.ceil(off * gs) + 1 # the texels a quad can sample (tmpfont.export_fonts)
575
+
576
+ # characters -> glyphs (a synthesized control character the font lacks: TMP's zero glyph 0)
577
+ characters, glyph_of, missing = {}, {}, set()
578
+ for u in sorted(set(chars) | set(tmpfont.TMP_SYNTHESIZED) | ({UNDERLINE_CHARACTER} if underline else set())):
579
+ gi = gen.glyph_index(u)
580
+ if gi == 0 and u not in tmpfont.TMP_SYNTHESIZED:
581
+ if u in chars: # a font without '_' has no underline glyph, nothing is shown
582
+ missing.add(u)
583
+ continue
584
+ characters[str(u)] = {"glyph": gi, "scale": 1.0, "elementType": ELEMENT_CHARACTER}
585
+ glyph_of.setdefault(gi, u)
586
+ cells, recs = [], {}
587
+ for gi, u in sorted(glyph_of.items(), key=lambda kv: (kv[1] != UNDERLINE_CHARACTER, kv[1])):
588
+ if gi == 0:
589
+ recs[gi] = ({f: 0.0 for f in tmpfont.GLYPH_METRIC_FIELDS}, (0, 0, 0, 0), None)
590
+ continue
591
+ m, box, a = glyph_cell(gen, font, gi, margin)
592
+ recs[gi] = (m, box, a)
593
+ if box[2] > 0 and box[3] > 0:
594
+ cells.append((gi, box[2] + 2 * margin, box[3] + 2 * margin))
595
+ where, width, height = pack_cells(cells)
596
+ under = gen.glyph_index(UNDERLINE_CHARACTER)
597
+ if under in where and where[under][0] != 0:
598
+ raise RuntimeError(f"{name}: U+005F is not on the first page")
599
+ npages = 1 + max([p for p, _, _ in where.values()] + [0])
600
+ arrays = [np.zeros((height, width, 4), np.uint8) for _ in range(npages)]
601
+ page_names = [f"font_{name}_{i}" for i in range(npages)]
602
+ glyphs = {}
603
+ for gi, (m, box, a) in sorted(recs.items()):
604
+ rec = {"metrics": m, "rect": {"m_X": 0, "m_Y": 0, "m_Width": 0, "m_Height": 0}, "scale": 1.0,
605
+ "atlasIndex": 0}
606
+ if gi in where:
607
+ p, x, y = where[gi]
608
+ arrays[p][y:y + a.shape[0], x:x + a.shape[1], 3] = a
609
+ rec["rect"] = {"m_X": x + margin, "m_Y": y + margin, "m_Width": box[2], "m_Height": box[3]}
610
+ rec["atlasIndex"] = p
611
+ rec["packed"] = {"texture": page_names[p], "dx": 0, "dy": 0}
612
+ glyphs[str(gi)] = rec
613
+ textures = {pn: {"texture": f"{PAGES_DIR}/{_safe(pn)}.png", "name": pn, "width": width, "height": height,
614
+ "mipCount": 1, "settings": dict(PAGE_SETTINGS)} for pn in page_names}
615
+ base = name[:-4] if name.endswith(" SDF") else name
616
+ default = f"{name} Material"
617
+ materials = {default: _own_material(game["material"], default, page_names[0], width, height)}
618
+ renames = {}
619
+ for mname, m in sorted(text_materials.items()):
620
+ new = f"{base} - {textstyle.material_type(mname)}"
621
+ materials[new] = _own_material(m, new, page_names[0], width, height)
622
+ renames[mname] = new
623
+ generator = {"renderMode": render_mode, "gameRenderMode": game["renderMode"], "oversample": k,
624
+ "pointSize": point, "padding": pad, "margin": margin,
625
+ "algorithm": gen.ALGORITHM + (f", rasterized at {k}x per texel" if k > 1 else ""),
626
+ "mapping": gen.MAPPING, "freetype": ".".join(map(str, gen.ft.version()))}
627
+ asset = {"faceInfo": face_info(gen, font, point),
628
+ "atlasWidth": width, "atlasHeight": height, "atlasPadding": pad, "atlasRenderMode": render_mode,
629
+ "atlasPopulationMode": 0, "atlases": page_names, **{kk: game["style"][kk] for kk in STYLE_KEYS},
630
+ "material": default, "fallbacks": [], "glyphPairAdjustmentRecords": 0,
631
+ "characters": characters, "glyphs": glyphs, "runtimeCharacters": [], "runtimeGlyphs": None,
632
+ "source": font.source(generator), "subset": SUBSET}
633
+ return {"font": asset, "textures": textures, "pages": list(zip(page_names, arrays)), "materials": materials,
634
+ "renames": renames, "missing": missing}
635
+
636
+
637
+ def line_breaking(player) -> dict:
638
+ """TMP_Settings' line breaking rules: the text of its leading and following characters TextAssets (as stored)
639
+ and m_UseModernHangulLineBreakingRules."""
640
+ o = player.resource("TMP Settings")
641
+ tt = player.mono(o)
642
+
643
+ def text(key: str) -> str:
644
+ t = player.deref(o, tt[key])
645
+ return t.read_typetree()["m_Script"] if t is not None else ""
646
+ return {"leading": text("m_leadingCharacters"), "following": text("m_followingCharacters"),
647
+ "useModernHangulLineBreakingRules": bool(tt["m_UseModernHangulLineBreakingRules"])}
648
+
649
+
650
+ def search_order(fallbacks_of, font, exported: set) -> list[str]:
651
+ """The font assets TMP searches after `font` for a character `font` lacks, as tmpfont.FontSet.lookup
652
+ (TMP_FontAssetUtilities.GetCharacterFromFontAsset_Internal: each fallback, then that fallback's own fallbacks,
653
+ depth first, every asset once per search), reduced to the `exported` names, without `font` itself.
654
+ `fallbacks_of(f)`: the fallback assets of f (objects with a `name`)."""
655
+ out: list[str] = []
656
+ searched: set = set()
657
+
658
+ def walk(f) -> None:
659
+ for fb in fallbacks_of(f):
660
+ if fb.name in searched:
661
+ continue
662
+ searched.add(fb.name)
663
+ if fb.name in exported and fb.name != font.name:
664
+ out.append(fb.name)
665
+ walk(fb)
666
+ walk(font)
667
+ return out
668
+
669
+
670
+ def fallback_chains(ex: Exporter, primaries: set, exported: set) -> dict[str, list[str]]:
671
+ """{exported font asset: its search_order}, over the game's font assets reachable from the `primaries` (font
672
+ asset names): the fallback list of a font record once the assets that serve no shown character are left out."""
673
+ fs = tmpfont.FontSet(ex)
674
+ for n in sorted(primaries):
675
+ fs.by_key(textstyle.font_dir(n) + n)
676
+ missing = sorted(exported - set(fs.fonts))
677
+ if missing:
678
+ raise RuntimeError(f"font assets {missing} are not reachable from {sorted(primaries)}")
679
+ return {n: search_order(fs.fallbacks, fs.fonts[n], exported) for n in sorted(exported)}
680
+
681
+
682
+ FALLBACK_FLOATS = ("_GradientScale", "_TextureWidth", "_TextureHeight", "_WeightNormal", "_WeightBold")
683
+ SCALE_CLAMP = 1.0 # ShaderUtilities.m_clamp
684
+
685
+
686
+ def no_font_file(language: str) -> FontFile:
687
+ """The font_of of a build given the font file of its own language only: every other language is a ConfigError
688
+ naming the setting."""
689
+ raise ConfigError(f"the fallback chain of the open font assets needs the font file of {language}: give it as "
690
+ f"`{language}` in the [paths.fonts] table of the config file (`fonts.{language}` in [paths]), "
691
+ f"the environment variable NNNOTES_PATHS_FONTS_{language.upper().replace('-', '_')} or "
692
+ f"--font {language}=PATH")
693
+
694
+
695
+ def counterpart_language(lang: dict, language: str, game_name: str) -> str | None:
696
+ """The language whose open font file stands for the game font asset `game_name` in a fallback chain built for
697
+ `language`: `language` when LocalizeManager (`lang`, textstyle.language_fonts) lists the asset for it (font
698
+ names and additional font names), else the first language in LanguageMode order that lists it; None when no
699
+ language lists it (the game's own fallback assets, which no open font stands for)."""
700
+ def lists(code: str) -> bool:
701
+ d = lang["languages"].get(languages.mode(code)) or {}
702
+ return game_name in {f"{n} SDF" for n in d.get("fontNames", []) + d.get("additionalFontNames", [])}
703
+ if lists(language):
704
+ return language
705
+ return next((c for c in sorted(languages.LANGUAGES, key=languages.mode) if lists(c)), None)
706
+
707
+
708
+ def scale_ratios32(floats: dict, keywords) -> tuple[float, float | None]:
709
+ """ShaderUtilities.UpdateShaderRatios in float32: (_ScaleRatioA, _ScaleRatioC, None without the underlay
710
+ properties); RATIOS_OFF gives 1."""
711
+ f = np.float32
712
+ ratios = "RATIOS_OFF" not in keywords
713
+ gs, dilate = f(floats["_GradientScale"]), f(floats["_FaceDilate"])
714
+ weight = f(max(f(floats.get("_WeightNormal", 0.0)), f(floats.get("_WeightBold", 0.0))) / f(4.0))
715
+ t = max(f(1.0), f(f(f(weight + dilate) + f(floats.get("_OutlineWidth", 0.0)))
716
+ + f(floats.get("_OutlineSoftness", 0.0))))
717
+ a = f(f(gs - f(SCALE_CLAMP)) / f(gs * t)) if ratios else f(1.0)
718
+ c = None
719
+ if "_UnderlayOffsetX" in floats:
720
+ rng = f(f(weight + dilate) * f(gs - f(SCALE_CLAMP)))
721
+ t = max(f(1.0), f(f(max(abs(f(floats["_UnderlayOffsetX"])), abs(f(floats.get("_UnderlayOffsetY", 0.0))))
722
+ + f(floats.get("_UnderlayDilate", 0.0))) + f(floats.get("_UnderlaySoftness", 0.0))))
723
+ c = float(f(max(f(0.0), f(f(gs - f(SCALE_CLAMP)) - rng)) / f(gs * t))) if ratios else 1.0
724
+ return float(a), c
725
+
726
+
727
+ def fallback_material(m: dict, target: dict, name: str) -> dict:
728
+ """TMP_MaterialManager.GetFallbackMaterial(m, target) (TMP_Settings.matchMaterialPreset): the text material `m`
729
+ named `name`, with the _MainTex, _GradientScale, _TextureWidth, _TextureHeight, _WeightNormal and _WeightBold of
730
+ the fallback asset's default material `target`, and the scale ratios UpdateShaderRatios gives for those values
731
+ (scale_ratios32; a material without _FaceDilate keeps its own)."""
732
+ out = copy.deepcopy(m)
733
+ out["material"] = name
734
+ out["textures"]["_MainTex"] = copy.deepcopy(target["textures"]["_MainTex"])
735
+ for k in FALLBACK_FLOATS:
736
+ out["floats"][k] = target["floats"][k]
737
+ if "_FaceDilate" in out["floats"]:
738
+ a, c = scale_ratios32(out["floats"], out["keywords"])
739
+ out["floats"]["_ScaleRatioA"] = a
740
+ if c is not None:
741
+ out["floats"]["_ScaleRatioC"] = c
742
+ return out
743
+
744
+
745
+ def _game_font_record(ex: Exporter, f: tmpfont.Font) -> dict:
746
+ """The game font asset settings open_asset takes: point size, padding, render mode, style, default material."""
747
+ return {"pointSize": f.tt["m_FaceInfo"]["m_PointSize"], "padding": f.tt["m_AtlasPadding"],
748
+ "renderMode": f.tt["m_AtlasRenderMode"], "style": {k: f.tt[k] for k in STYLE_KEYS},
749
+ "material": ex.material(f.material_obj)}
750
+
751
+
752
+ def open_font_set(cat, player, ex: Exporter, language: str, font: FontFile, bindings: list[dict], shown: list[str],
753
+ ui_dir: Path, font_of=no_font_file, sprite_drawn: dict | None = None,
754
+ margin_materials: dict | None = None) -> dict:
755
+ """The open font assets of the text `bindings` (their `localized` game font asset and material, replaced in
756
+ place by the open asset and its material) in `language`, for the texts `shown`:
757
+
758
+ - per localized game font asset (a primary) an asset of `font` (the language's font file) with the game asset's
759
+ point size, padding, style settings, render mode and text materials (open_asset);
760
+ - the game asset's fallback chain mirrored: each asset of its search order (search_order) stands for an asset of
761
+ the font file of its counterpart_language (`font_of(language)` -> FontFile, asked for when a character
762
+ reaches it) with that game asset's settings; the game's own fallback assets no language lists are left out;
763
+ - each shown code point goes to the first asset of its primary's chain whose font file maps it, as
764
+ TMP_Text.GetTextElement finds a character; one no asset maps stays with the primary as `missing`. The code
765
+ points the game's chain lacks take the missing glyph (tmpfont.missing_glyph: the primary gets `missingGlyph`;
766
+ its substitute goes through the chain like any character) and those a sprite draws for the primary's texts
767
+ (`sprite_drawn` {game font asset: code points}) are left out;
768
+ - an asset's `fallbacks` are the assets of its chain that hold a character (in search order), and each text
769
+ material M of a primary has, per fallback of the primary, the fallback material "M + <fallback asset>"
770
+ (fallback_material).
771
+
772
+ `margin_materials` {game font asset: game material names}: further text materials of that asset whose underlay
773
+ the glyph margin covers (so the pages equal another document's with the same characters). The pages go to
774
+ `ui_dir`/fonts/. -> {fonts, textures, materials, missing (code points no asset maps), substituted (code points
775
+ drawn as a missing glyph)}."""
776
+ lang = textstyle.language_fonts(player)
777
+ sprite_drawn = sprite_drawn or {}
778
+ margin_materials = margin_materials or {}
779
+ chars = sorted({ord(ch) for s in shown for ch in s})
780
+ by_game: dict[str, list[dict]] = {}
781
+ for t in bindings:
782
+ by_game.setdefault(t["localized"]["fontAsset"], []).append(t)
783
+ primaries = sorted(by_game)
784
+ fs = tmpfont.FontSet(ex)
785
+ missing_char = tmpfont.missing_glyph_character(player)
786
+ files = {language: font}
787
+
788
+ def file_of(code: str) -> FontFile:
789
+ if code not in files:
790
+ files[code] = font_of(code)
791
+ return files[code]
792
+
793
+ def chain(name: str, code: str) -> list[tuple[str, str]]:
794
+ out = [(name, code)]
795
+ for fb in search_order(fs.fallbacks, fs.fonts[name], set(fs.fonts)):
796
+ c = counterpart_language(lang, language, fb)
797
+ if c is not None and (fb, c) not in out:
798
+ out.append((fb, c))
799
+ return out
800
+
801
+ # the game side per primary: missing glyph, underline glyph, text materials; the characters through the chain
802
+ held: dict[tuple[str, str], set[int]] = {}
803
+ mgs, text_mats, margins, missing, substituted = {}, {}, {}, set(), set()
804
+ for name in primaries:
805
+ g = fs.by_key(advui.font_key(cat, name, name))
806
+ tmpfont.check_glyph_variants(fs, g, shown)
807
+ mg = tmpfont.missing_glyph(fs, g, chars, missing_char)
808
+ gone = set(mg["characters"]) if mg else set()
809
+ substituted |= gone
810
+ mgs[name] = mg
811
+ # GetUnderlineSpecialCharacter: '_' of the game asset itself (no fallback); a '_' its fallback draws for the
812
+ # text would be the underline glyph too in one open asset
813
+ if not tmpfont.underline_character(fs, g) and UNDERLINE_CHARACTER in chars and UNDERLINE_CHARACTER not in gone:
814
+ raise NotImplementedError(f"{name}: U+005F from a fallback without the asset's own underline glyph")
815
+ mats = {}
816
+ for t in by_game[name]:
817
+ mname = t["localized"]["material"]
818
+ if mname not in mats:
819
+ key = advui.font_key(cat, name, mname)
820
+ if not cat.has(key):
821
+ raise KeyError(f"material key {key}")
822
+ mats[mname] = ex.material(ex.key_object(key))
823
+ text_mats[name] = mats
824
+ margins[name] = dict(mats)
825
+ for mname in sorted(margin_materials.get(name, ())):
826
+ key = advui.font_key(cat, name, mname)
827
+ if mname not in margins[name] and cat.has(key):
828
+ margins[name][mname] = ex.material(ex.key_object(key))
829
+ own = (name, language)
830
+ held.setdefault(own, set())
831
+ want = sorted({u for u in chars if u not in gone and u not in sprite_drawn.get(name, ())}
832
+ | ({mg["unicode"]} if mg else set()))
833
+ steps = chain(name, language)
834
+ for u in want:
835
+ if u in tmpfont.TMP_SYNTHESIZED: # every asset synthesizes these: found in the primary
836
+ held[own].add(u)
837
+ continue
838
+ hit = next((k for k in steps if file_of(k[1]).maps(u)), own)
839
+ held.setdefault(hit, set()).add(u) # no asset maps it: the primary (open_asset: missing)
840
+
841
+ # the assets: the primaries, then the chain assets that hold a character
842
+ prim_keys = [(n, language) for n in primaries]
843
+ keys = prim_keys + sorted(k for k in held if k not in prim_keys and held[k])
844
+ single = len(primaries) == 1
845
+
846
+ def name_of(key: tuple[str, str]) -> str:
847
+ if key[1] == language and key[0] in by_game:
848
+ return font.asset_name if single else f"{font.asset_name} ({key[0]})"
849
+ return f"{file_of(key[1]).asset_name} ({key[0]})"
850
+
851
+ names = {k: name_of(k) for k in keys}
852
+ if len(set(names.values())) != len(names):
853
+ raise RuntimeError(f"open font asset names collide: {sorted(names.values())}")
854
+ users = {k: [n for n in primaries if k in chain(n, language)] for k in keys}
855
+ fonts, textures, materials, renames = {}, {}, {}, {}
856
+ for key in keys:
857
+ gname, code = key
858
+ primary = gname in by_game and code == language
859
+ margin = margins[gname] if primary else {m: r for n in users[key] for m, r in margins[n].items()}
860
+ name = names[key]
861
+ a = open_asset(file_of(code), name, sorted(held[key]), _game_font_record(ex, fs.fonts[gname]), margin,
862
+ tmpfont.underline_character(fs, fs.fonts[gname]) if primary else False)
863
+ used = {f"{name} Material"} | ({a["renames"][m] for m in text_mats[gname]} if primary else set())
864
+ a["font"]["fallbacks"] = [names[k] for k in chain(gname, code)[1:] if k in names and k != key]
865
+ if primary and mgs[gname]:
866
+ a["font"]["missingGlyph"] = mgs[gname]
867
+ fonts[name] = a["font"]
868
+ textures.update(a["textures"])
869
+ materials.update({k: v for k, v in a["materials"].items() if k in used})
870
+ missing |= a["missing"]
871
+ if primary:
872
+ renames[gname] = a["renames"]
873
+ (ui_dir / PAGES_DIR).mkdir(parents=True, exist_ok=True)
874
+ for pname, px in a["pages"]:
875
+ (ui_dir / a["textures"][pname]["texture"]).write_bytes(packed_png(px))
876
+ # the fallback materials of the primaries' text materials, then the bindings
877
+ for gname in primaries:
878
+ prim = fonts[names[(gname, language)]]
879
+ for fb in prim["fallbacks"]:
880
+ target = materials[fonts[fb]["material"]]
881
+ for mname in sorted(text_mats[gname]):
882
+ own = renames[gname][mname]
883
+ materials[f"{own} + {fb}"] = fallback_material(materials[own], target, f"{own} + {fb}")
884
+ for t in by_game[gname]:
885
+ loc = t["localized"]
886
+ t["localized"] = {**loc, "fontAsset": names[(gname, language)], "material": renames[gname][loc["material"]]}
887
+ return {"fonts": fonts, "textures": textures, "materials": materials, "missing": missing,
888
+ "substituted": substituted}
889
+
890
+
891
+ def open_fonts(cat, player, episode: dict, ui_dir: Path, language: str, font: FontFile,
892
+ master_dir: Path | None = None, emoji: EmojiFont | None = None, font_of=no_font_file) -> dict:
893
+ """ui/fonts.json, ui/fonts/*.png and ui/languages.json of `language` from the font file `font` and the emoji font
894
+ `emoji` (module docstring; without one the sprites keep their metrics and have no texels), next to the
895
+ ui/ui.json advui.extract wrote for that language in `ui_dir`; the text shaders go to ui/shaders. `font_of`:
896
+ the font file of another language for the fallback chains (open_font_set). Returns a summary."""
897
+ tmpfont.require_extra()
898
+ ui_dir = Path(ui_dir)
899
+ ui_doc = json.loads((ui_dir / "ui.json").read_text(encoding="utf-8"))
900
+ mode, field = languages.mode(language), languages.column(language)[1:]
901
+ ex = _exporter(cat, player, ui_dir / "_fonts")
902
+ lang = textstyle.language_fonts(player)
903
+ sprite_name = emoji.asset_name if emoji is not None else NO_EMOJI_FONT
904
+ texts = text_bindings(ex, ui_doc, lang, mode, sprite_name)
905
+ dialogs = dialog_bindings(ex, ui_doc, lang, mode, sprite_name)
906
+ chat = chat_bindings(ex, episode, lang, mode, sprite_name) if ui_doc.get("chatTexts") else {}
907
+ frames = frame_prefabs(ex, episode)
908
+ frame = frame_bindings(frames, lang, mode, sprite_name)
909
+ shown = shown_texts(episode, ui_doc, master_dir, field) + frame_texts(frames, episode, field)
910
+ chars = sorted({ord(ch) for s in shown for ch in s})
911
+
912
+ bindings = list(texts.values()) + [t for group in (dialogs, chat, frame) for w in group.values()
913
+ for t in w.values()]
914
+ # the code points the sprite asset draws for the texts with it (sprite_drawn); the open font asset of those
915
+ # texts leaves them out
916
+ sprite_drawn: dict[str, set[int]] = {}
917
+ rec = None
918
+ if any("spriteAsset" in t for t in bindings):
919
+ rec = advui.sprite_asset_record(ex)
920
+ sprite_drawn = drawn_by_sprites(ex, rec, [t for t in bindings if "spriteAsset" in t], chars)
921
+ # the open font assets with the mirrored fallback chains, the missing glyph and the fallback materials
922
+ fset = open_font_set(cat, player, ex, language, font, bindings, shown, ui_dir, font_of, sprite_drawn)
923
+ fonts, textures, materials = fset["fonts"], fset["textures"], fset["materials"]
924
+ missing, substituted = fset["missing"], fset["substituted"]
925
+ coverage = {"characters": len(chars), "missing": [chr(u) for u in sorted(missing)]}
926
+ if substituted:
927
+ coverage["missingGlyph"] = [chr(u) for u in sorted(substituted)]
928
+ sprites = None
929
+ indices = sprite_needs(rec, shown, set().union(*sprite_drawn.values())) if rec is not None else []
930
+ if not indices: # no sprite to draw: the data holds no sprite asset
931
+ for t in bindings:
932
+ t.pop("spriteAsset", None)
933
+ t.pop("m_tintAllSprites", None)
934
+ else:
935
+ sprites = open_sprite_asset(emoji, rec, indices, sprite_name)
936
+ for pname, px in sprites["pages"]:
937
+ (ui_dir / PAGES_DIR).mkdir(parents=True, exist_ok=True)
938
+ (ui_dir / sprites["textures"][pname]["texture"]).write_bytes(packed_png(px))
939
+ textures.update(sprites["textures"])
940
+ materials[sprites["material"]["material"]] = sprites["material"]
941
+ coverage["sprites"] = {"characters": len(sprites["asset"]["characters"]), "missing": sprites["missing"]}
942
+ keywords = text_shaders(ex, ui_dir, materials)
943
+ fonts_doc = {"format": FONTS_FORMAT, "language": language, "source": "open", "fonts": fonts,
944
+ "textures": textures, "materials": materials, "materialKeywords": keywords, "texts": texts,
945
+ "coverage": coverage, "lineBreaking": line_breaking(player)}
946
+ if sprites is not None:
947
+ fonts_doc["spriteAssets"] = {sprite_name: sprites["asset"]}
948
+ fonts_doc["emojiSpriteAsset"] = sprite_name
949
+ if dialogs:
950
+ fonts_doc["dialogTexts"] = dialogs
951
+ if chat:
952
+ fonts_doc["chatTexts"] = chat
953
+ if frame:
954
+ fonts_doc["frameTexts"] = frame
955
+ _write(ui_dir, fonts_doc, language_doc(language, "open", fonts, texts, lang))
956
+ shutil.rmtree(ui_dir / "_fonts", ignore_errors=True)
957
+ return {"fonts": sorted(fonts), "characters": len(chars), "missing": len(missing),
958
+ "glyphs": sum(len(f["glyphs"]) for f in fonts.values()), "pages": len(textures),
959
+ "sprites": len(sprites["asset"]["characters"]) if sprites else 0,
960
+ "missingSprites": len(sprites["missing"]) if sprites else 0}
961
+
962
+
963
+ # ---------------------------------------------------------------- emoji sprites (open)
964
+ EMOJI_VS16 = 0xFE0F
965
+ SPRITE_MARGIN = 1 # transparent texels around each sprite cell (a bilinear quad edge samples half a texel)
966
+ TMP_MISSING_SPRITE_UNICODE = 0 # TMP_Settings.missingCharacterSpriteUnicode of the game's settings
967
+ NO_EMOJI_FONT = "Emoji Sprites" # the open sprite asset's name without an emoji font
968
+ SPRITE_SUBSET = ("sprites of the emoji sequences in the episode's texts in this language (TMP_EmojiSearchEngine) "
969
+ "and of the characters they show that the game's font assets lack")
970
+
971
+
972
+ def _ligatures(tt) -> dict[tuple, str]:
973
+ """{(glyph, glyph, ...): ligature glyph} of the GSUB ligature substitutions of a fontTools font (extension
974
+ lookups included)."""
975
+ out: dict[tuple, str] = {}
976
+ if "GSUB" not in tt:
977
+ return out
978
+ for lookup in tt["GSUB"].table.LookupList.Lookup:
979
+ for st in lookup.SubTable:
980
+ st = st.ExtSubTable if lookup.LookupType == 7 else st
981
+ for first, ligs in (getattr(st, "ligatures", None) or {}).items():
982
+ for lig in ligs:
983
+ out.setdefault((first, *lig.Component), lig.LigGlyph)
984
+ return out
985
+
986
+
987
+ class EmojiFont:
988
+ """A colour emoji font of one's own with PNG bitmap glyphs (CBDT / CBLC or sbix; e.g. Noto Color Emoji, SIL Open
989
+ Font License 1.1): the image of an emoji, a code point or a sequence resolved through the font's ligature
990
+ substitutions (with U+FE0F dropped when the full sequence has no ligature), from the largest strike."""
991
+
992
+ def __init__(self, path, face_index: int = 0):
993
+ import io
994
+
995
+ from fontTools.ttLib import TTFont
996
+ self.file = FontFile(path, face_index)
997
+ self.tt = TTFont(io.BytesIO(self.file.data), fontNumber=face_index)
998
+ self.cmap = self.tt.getBestCmap() or {}
999
+ self.ligatures = _ligatures(self.tt)
1000
+ if "CBDT" in self.tt:
1001
+ strikes = self.tt["CBLC"].strikes
1002
+ i = max(range(len(strikes)), key=lambda k: strikes[k].bitmapSizeTable.ppemY)
1003
+ self.ppem = strikes[i].bitmapSizeTable.ppemY
1004
+ self._glyphs = self.tt["CBDT"].strikeData[i]
1005
+ self._png = lambda g: getattr(g, "imageData", None)
1006
+ elif "sbix" in self.tt:
1007
+ self.ppem = max(self.tt["sbix"].strikes)
1008
+ self._glyphs = self.tt["sbix"].strikes[self.ppem].glyphs
1009
+ self._png = lambda g: g.imageData if g.graphicType == "png " else None
1010
+ else:
1011
+ raise ValueError(f"{self.file.path.name}: no CBDT or sbix bitmap table (a colour bitmap emoji font)")
1012
+
1013
+ def glyph(self, seq: str) -> str | None:
1014
+ """The glyph name of the emoji `seq` (a string of one or more code points), None when the font lacks it."""
1015
+ for s in (seq, "".join(c for c in seq if ord(c) != EMOJI_VS16)):
1016
+ names = [self.cmap.get(ord(c)) for c in s]
1017
+ if not names or None in names:
1018
+ continue
1019
+ if len(names) == 1:
1020
+ return names[0]
1021
+ lig = self.ligatures.get(tuple(names))
1022
+ if lig:
1023
+ return lig
1024
+ return None
1025
+
1026
+ def image(self, seq: str, width: int, height: int):
1027
+ """RGBA array (height x width, bottom row first, straight alpha) of the emoji `seq`: its bitmap centred in a
1028
+ square of the larger side, resampled (Lanczos on premultiplied alpha, Pillow) to width x height; None when
1029
+ the font lacks it."""
1030
+ import io
1031
+
1032
+ from PIL import Image
1033
+ name = self.glyph(seq)
1034
+ g = self._glyphs.get(name) if name else None
1035
+ data = self._png(g) if g is not None else None
1036
+ if not data:
1037
+ return None
1038
+ im = Image.open(io.BytesIO(data)).convert("RGBA")
1039
+ side = max(im.size)
1040
+ sq = Image.new("RGBA", (side, side), (0, 0, 0, 0))
1041
+ sq.paste(im, ((side - im.size[0]) // 2, (side - im.size[1]) // 2))
1042
+ out = sq.convert("RGBa").resize((width, height), Image.Resampling.LANCZOS).convert("RGBA")
1043
+ return np.asarray(out, np.uint8)[::-1].copy()
1044
+
1045
+ @property
1046
+ def asset_name(self) -> str:
1047
+ return f"{self.file.family} {self.file.style} Sprites".replace(" ", " ")
1048
+
1049
+ def source(self) -> dict:
1050
+ return self.file.source({"strikePpem": self.ppem, "fit": "bitmap centred in a square, Lanczos resampling"})
1051
+
1052
+
1053
+ def sprite_sequence(name: str, unicode: int) -> str:
1054
+ """The emoji a sprite of the game's emoji sprite asset stands for: its sequence (advui.emoji_sequence_key of its
1055
+ name), else its code point."""
1056
+ return advui.emoji_sequence_key(name, unicode) or chr(unicode)
1057
+
1058
+
1059
+ def drawn_by_sprites(ex: Exporter, rec: dict, bindings: list[dict], chars) -> dict[str, set[int]]:
1060
+ """{game font asset: the code points of `chars` the sprite asset record `rec` draws for the texts of `bindings`
1061
+ that use that font asset}: the ones the font asset lacks with its fallbacks (tmpfont.FontSet lookup, as
1062
+ TMP_Text.GetTextElement before the sprite asset) and a sprite character of `rec` has."""
1063
+ fs = tmpfont.FontSet(ex)
1064
+ have = {c["unicode"] for c in rec["characters"] if str(c["glyph"]) in rec["glyphs"]}
1065
+ out = {}
1066
+ for game_name in sorted({t["localized"]["fontAsset"] for t in bindings}):
1067
+ f = fs.by_key(advui.font_key(ex.cat, game_name, game_name))
1068
+ out[game_name] = {u for u in chars if u in have and fs.lookup(f, u) is None}
1069
+ return out
1070
+
1071
+
1072
+ def sprite_needs(rec: dict, texts: list[str], drawn: set[int]) -> list[int]:
1073
+ """The sprite character indices of the sprite asset record `rec` (advui.sprite_asset_record) that `texts` can
1074
+ draw: the sprites of the emoji sequences TMP_EmojiSearchEngine finds in them (the <sprite name> tag's lookup: the
1075
+ first character whose name has the value's hash, else the missing character sprite) and those of the code
1076
+ points `drawn` (the first character with the code point). Characters whose glyph the asset lacks are left out,
1077
+ as TMP_SpriteAsset.UpdateLookupTables does."""
1078
+ by_hash: dict[int, dict] = {}
1079
+ by_unicode: dict[int, dict] = {}
1080
+ for c in rec["characters"]:
1081
+ if str(c["glyph"]) not in rec["glyphs"]:
1082
+ continue
1083
+ by_hash.setdefault(advui.tmp_hash(c["name"]), c)
1084
+ if c["unicode"] != 0xFFFE:
1085
+ by_unicode.setdefault(c["unicode"], c)
1086
+ table, fast = advui.emoji_sequence_table(rec["sequences"])
1087
+ out = {by_unicode[u]["index"] for u in drawn if u in by_unicode}
1088
+ for s in texts:
1089
+ for name in advui.emoji_sequence_names(table, fast, s):
1090
+ c = by_hash.get(advui.tmp_hash(name)) or by_unicode.get(TMP_MISSING_SPRITE_UNICODE)
1091
+ if c is not None:
1092
+ out.add(c["index"])
1093
+ return sorted(out)
1094
+
1095
+
1096
+ def _sheet_material(m: dict, name: str, page: str | None, width: int, height: int) -> dict:
1097
+ """A game sprite material for an open sprite asset: named `name`, sampling `page` (width x height; None: no
1098
+ texture) as _MainTex; every other value as in the game."""
1099
+ m = copy.deepcopy(m)
1100
+ _material_textures(m)
1101
+ m["material"] = name
1102
+ tex = m["textures"].get("_MainTex") or {}
1103
+ m["textures"]["_MainTex"] = {"texture": {"name": page, "width": width, "height": height,
1104
+ "format": (tex.get("texture") or {}).get("format")} if page else None,
1105
+ "scale": tex.get("scale", {"x": 1.0, "y": 1.0}),
1106
+ "offset": tex.get("offset", {"x": 0.0, "y": 0.0})}
1107
+ return m
1108
+
1109
+
1110
+ def open_sprite_asset(emoji: EmojiFont | None, game: dict, indices: list[int], name: str) -> dict:
1111
+ """An open sprite asset `name` in place of the game's sprite asset record `game` (advui.sprite_asset_record)
1112
+ holding the sprite characters `indices` (character table indices): the game's face info, character entries,
1113
+ glyph metrics and sequence list; each glyph's image drawn from `emoji` at the game glyph rect's size, shelf-packed
1114
+ (SPRITE_MARGIN transparent texels around each) into one page. A glyph without an image (no emoji font, or one
1115
+ that lacks the emoji) keeps its metrics with an empty rect and no page. -> {asset, textures, pages, material,
1116
+ missing (the names of the sprites without an image)}. A character whose glyph the record lacks is left out."""
1117
+ chars = [c for c in (game["characters"][i] for i in sorted(set(indices))) if str(c["glyph"]) in game["glyphs"]]
1118
+ glyph_ids = sorted({c["glyph"] for c in chars})
1119
+ images, missing, cells = {}, [], []
1120
+ for gi in glyph_ids:
1121
+ c = next(c for c in chars if c["glyph"] == gi)
1122
+ r = game["glyphs"][str(gi)]["rect"]
1123
+ w, h = r["m_Width"], r["m_Height"]
1124
+ if w <= 0 or h <= 0:
1125
+ continue
1126
+ a = emoji.image(sprite_sequence(c["name"], c["unicode"]), w, h) if emoji is not None else None
1127
+ if a is None:
1128
+ missing.append(c["name"])
1129
+ continue
1130
+ images[gi] = a
1131
+ cells.append((gi, w + 2 * SPRITE_MARGIN, h + 2 * SPRITE_MARGIN))
1132
+ where, width, height = pack_cells(cells)
1133
+ if any(pg for pg, _, _ in where.values()):
1134
+ raise NotImplementedError(f"{name}: sprites on more than one page")
1135
+ page = f"sprite_{name}" if images else None
1136
+ arr = np.zeros((height, width, 4), np.uint8) if images else None
1137
+ glyphs = {}
1138
+ for gi in glyph_ids:
1139
+ g = game["glyphs"][str(gi)]
1140
+ rec = {"metrics": g["metrics"], "rect": {"m_X": 0, "m_Y": 0, "m_Width": 0, "m_Height": 0}, "scale": g["scale"],
1141
+ "atlasIndex": 0}
1142
+ if gi in images:
1143
+ a = images[gi]
1144
+ _, x, y = where[gi]
1145
+ x, y = x + SPRITE_MARGIN, y + SPRITE_MARGIN
1146
+ arr[y:y + a.shape[0], x:x + a.shape[1]] = a
1147
+ rec["rect"] = {"m_X": x, "m_Y": y, "m_Width": a.shape[1], "m_Height": a.shape[0]}
1148
+ rec["packed"] = {"texture": page, "dx": 0, "dy": 0}
1149
+ glyphs[str(gi)] = rec
1150
+ textures = ({page: {"texture": f"{PAGES_DIR}/{_safe(page)}.png", "name": page, "width": width, "height": height,
1151
+ "mipCount": 1, "settings": dict(PAGE_SETTINGS)}} if images else {})
1152
+ material = _sheet_material(game["material"], f"{name} Material", page, width, height)
1153
+ asset = {"faceInfo": game["faceInfo"], "characters": chars, "glyphs": glyphs, "sequences": game["sequences"],
1154
+ "material": material["material"], "source": emoji.source() if emoji is not None else None,
1155
+ "subset": SPRITE_SUBSET}
1156
+ return {"asset": asset, "textures": textures, "pages": [(page, arr)] if images else [], "material": material,
1157
+ "missing": missing}
1158
+
1159
+
1160
+ # ---------------------------------------------------------------- game fonts
1161
+ def game_sprite_asset(ex: Exporter, ui_dir: Path, rec: dict) -> dict:
1162
+ """LocalizeManager's emoji sprite asset from the game data (`rec`, advui.sprite_asset_record): every character and
1163
+ glyph, the sprite sheet as one page (ui/fonts/, the glyph rects unchanged) and its material sampling it.
1164
+ -> {asset_name, asset, textures, material}."""
1165
+ sh = rec["sheet"]
1166
+ if sh["mipCount"] != 1:
1167
+ raise NotImplementedError(f"{rec['name']}: sprite sheet with {sh['mipCount']} mip levels")
1168
+ o = ex.key_object(advui.EMOJI_SPRITE_ASSET_KEY)
1169
+ sheet = ex.deref(o, o.read_typetree()["spriteSheet"])
1170
+ page = f"sprite_{rec['name']}"
1171
+ dst = f"{PAGES_DIR}/{_safe(page)}.png"
1172
+ (Path(ui_dir) / PAGES_DIR).mkdir(parents=True, exist_ok=True)
1173
+ (Path(ui_dir) / dst).write_bytes(texture_png(sheet.read()))
1174
+ glyphs = {k: {**g, **({"packed": {"texture": page, "dx": 0, "dy": 0}}
1175
+ if g["rect"]["m_Width"] > 0 and g["rect"]["m_Height"] > 0 else {})}
1176
+ for k, g in rec["glyphs"].items()}
1177
+ material = _sheet_material(rec["material"], rec["material"]["material"], page, sh["width"], sh["height"])
1178
+ asset = {"faceInfo": rec["faceInfo"], "characters": rec["characters"], "glyphs": glyphs,
1179
+ "sequences": rec["sequences"], "material": material["material"]}
1180
+ textures = {page: {"texture": dst, "name": sh["name"], "width": sh["width"], "height": sh["height"],
1181
+ "mipCount": 1, "settings": sh["settings"]}}
1182
+ return {"asset_name": rec["name"], "asset": asset, "textures": textures, "material": material}
1183
+
1184
+
1185
+ def game_fonts(cat, player, game_ui_dir: Path, ui_dir: Path, language: str, episode: dict | None = None,
1186
+ master_dir: Path | None = None) -> dict:
1187
+ """ui/fonts.json, ui/fonts/*.png and ui/languages.json of `language` from the ui/ directory advui.extract wrote
1188
+ with fonts="game" for that language (`game_ui_dir`): its font records (each font's `fallbacks` reduced to the
1189
+ exported assets in search order, fallback_chains), glyph pages, text materials, keyword sets, text records, glyph
1190
+ coverage and TMP settings, next to the fonts-open ui/ui.json in `ui_dir`; the text shaders go to ui/shaders as
1191
+ with open fonts. With the `episode` (and the master data for its UI labels), the texts a UIText drives get the
1192
+ game's emoji sprite asset when they draw a sprite (sprite_needs; its code points leave coverage.missing). Returns a
1193
+ summary."""
1194
+ game_ui_dir, ui_dir = Path(game_ui_dir), Path(ui_dir)
1195
+ doc = json.loads((game_ui_dir / "ui.json").read_text(encoding="utf-8"))
1196
+ ui_doc = json.loads((ui_dir / "ui.json").read_text(encoding="utf-8"))
1197
+ texts = {n["path"]: n["text"] for n in doc["nodes"] if "text" in n}
1198
+ open_paths = {n["path"] for n in ui_doc["nodes"] if "textStyle" in n}
1199
+ if set(texts) != open_paths:
1200
+ raise RuntimeError("the text nodes of the fonts-game and fonts-open UI differ")
1201
+ fonts = doc["fonts"]
1202
+ pages = sorted({g["packed"]["texture"] for f in fonts.values() for g in f["glyphs"].values() if "packed" in g})
1203
+ textures = {}
1204
+ (ui_dir / PAGES_DIR).mkdir(parents=True, exist_ok=True)
1205
+ for p in pages:
1206
+ t = dict(doc["textures"][p])
1207
+ dst = f"{PAGES_DIR}/{Path(t['texture']).name}"
1208
+ shutil.copyfile(game_ui_dir / t["texture"], ui_dir / dst)
1209
+ t["texture"] = dst
1210
+ textures[p] = t
1211
+ names = {t["localized"]["material"] for t in texts.values()} | {f["material"] for f in fonts.values()}
1212
+ materials = {n: doc["materials"][n] for n in sorted(names)}
1213
+ ex = _exporter(cat, player, ui_dir / "_fonts")
1214
+ chains = fallback_chains(ex, {t["localized"]["fontAsset"] for t in texts.values()}, set(fonts))
1215
+ fonts = {n: {**f, "fallbacks": chains[n]} for n, f in fonts.items()}
1216
+ for t in texts.values(): # TMP's fallback materials of the text materials (fallback_material)
1217
+ loc = t["localized"]
1218
+ for fb in fonts[loc["fontAsset"]]["fallbacks"]:
1219
+ name = f"{loc['material']} + {fb}"
1220
+ if name not in materials:
1221
+ materials[name] = fallback_material(materials[loc["material"]], materials[fonts[fb]["material"]], name)
1222
+ driven = story_ui_text_targets(ex, ui_doc) & set(texts)
1223
+ sprites, drawn = None, set()
1224
+ if episode is not None and driven:
1225
+ rec = advui.sprite_asset_record(ex)
1226
+ shown = shown_texts(episode, ui_doc, master_dir, languages.column(language)[1:])
1227
+ chars = sorted({ord(ch) for s in shown for ch in s})
1228
+ drawn = set().union(*drawn_by_sprites(ex, rec, [texts[p] for p in sorted(driven)], chars).values())
1229
+ if sprite_needs(rec, shown, drawn):
1230
+ sprites = game_sprite_asset(ex, ui_dir, rec)
1231
+ if sprites is None:
1232
+ driven, drawn = set(), set()
1233
+ sprite_name = sprites["asset_name"] if sprites else None
1234
+ for path, comps in text_components(ex, ui_doc).items():
1235
+ texts[path] = {**texts[path], **text_extras(path, comps),
1236
+ **sprite_binding(path, comps, path in driven, sprite_name)}
1237
+ if sprites is not None:
1238
+ textures.update(sprites["textures"])
1239
+ materials[sprites["material"]["material"]] = sprites["material"]
1240
+ for n in sorted({t["localized"]["material"] for t in texts.values()}): # the text shaders for text_shaders
1241
+ fa = next(t["localized"]["fontAsset"] for t in texts.values() if t["localized"]["material"] == n)
1242
+ ex.material(ex.key_object(textstyle.font_dir(fa) + n))
1243
+ keywords = text_shaders(ex, ui_dir, materials)
1244
+ cov = doc["glyphCoverage"]
1245
+ if sprites is not None:
1246
+ cov = {**cov, "missing": [c for c in cov["missing"] if ord(c) not in drawn],
1247
+ "sprites": {"characters": len(sprites["asset"]["characters"]), "missing": []}}
1248
+ fonts_doc = {"format": FONTS_FORMAT, "language": language, "source": "game", "fonts": fonts,
1249
+ "textures": textures, "materials": materials, "materialKeywords": keywords, "texts": texts,
1250
+ "coverage": cov, "tmpSettings": doc["tmpSettings"], "lineBreaking": line_breaking(player)}
1251
+ if sprites is not None:
1252
+ fonts_doc["spriteAssets"] = {sprite_name: sprites["asset"]}
1253
+ fonts_doc["emojiSpriteAsset"] = sprite_name
1254
+ lang = textstyle.language_fonts(player)
1255
+ _write(ui_dir, fonts_doc, language_doc(language, "game", fonts, texts, lang))
1256
+ shutil.rmtree(ui_dir / "_fonts", ignore_errors=True)
1257
+ return {"fonts": sorted(fonts), "characters": cov["characters"], "missing": len(cov["missing"]),
1258
+ "glyphs": sum(len(f["glyphs"]) for f in fonts.values()), "pages": len(textures)}