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/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)}
|