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/storyvoices.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""The story episodes as the voices index reads them: an episode's voice commands and the rows of its shards they
|
|
2
|
+
name, from the exported objects (the script's MonoBehaviour JSON, the shards' TextAsset bytes) or, for one episode
|
|
3
|
+
at the command line, from the catalog.
|
|
4
|
+
|
|
5
|
+
An episode (catalog key `Adv/Episode/<asset>/<asset>`, the voices rules' `story.key`) is an AdvEpisodeCollection
|
|
6
|
+
MonoBehaviour whose `Collection` is the command list; its shards are TextAssets of JSON `{"_header", "_allData"}`
|
|
7
|
+
at the same key with a suffix: `-Text` (text rows in the five languages, MasterText columns), `-Sound` (sound
|
|
8
|
+
rows, MasterSound columns) and `-SoundCueSheet` (cue sheet rows, MasterSoundCueSheet columns). `episode` keeps of
|
|
9
|
+
them what the voices index uses: the commands with voice ids and the rows those name, so an index of every episode
|
|
10
|
+
holds these parts only. Nothing here imports UnityPy before an episode is loaded from the catalog.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
|
|
16
|
+
PARTS = ("script", "text", "sound", "sheets")
|
|
17
|
+
COMMAND_FIELDS = ("Index", "Command", "TargetName", "TargetTextIDs", "AdvTextID", "VoiceIDs", "IgnoreData")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class StoryError(ValueError):
|
|
21
|
+
"""Episode data that is not of the form above."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def shard_rows(data: bytes) -> list[dict]:
|
|
25
|
+
"""The rows (`_allData`) of a shard's bytes."""
|
|
26
|
+
try:
|
|
27
|
+
doc = json.loads(bytes(data).decode("utf-8-sig"))
|
|
28
|
+
except (UnicodeDecodeError, ValueError) as e:
|
|
29
|
+
raise StoryError(f"not a shard: {e}") from None
|
|
30
|
+
rows = doc.get("_allData") if isinstance(doc, dict) else None
|
|
31
|
+
if not isinstance(rows, list):
|
|
32
|
+
raise StoryError("not a shard: no _allData list")
|
|
33
|
+
return rows
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def voice_commands(script: dict) -> list[dict]:
|
|
37
|
+
"""The commands of an episode script (its MonoBehaviour's fields) that carry voice ids, in the stored order,
|
|
38
|
+
with the fields the index reads."""
|
|
39
|
+
rows = script.get("Collection")
|
|
40
|
+
if not isinstance(rows, list):
|
|
41
|
+
raise StoryError("the script has no Collection")
|
|
42
|
+
out = []
|
|
43
|
+
for r in rows:
|
|
44
|
+
if isinstance(r, dict) and r.get("VoiceIDs"):
|
|
45
|
+
out.append({k: r[k] for k in COMMAND_FIELDS if k in r})
|
|
46
|
+
return out
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _by_id(rows: list[dict], ids) -> dict:
|
|
50
|
+
want = set(ids)
|
|
51
|
+
return {r["_id"]: r for r in rows if isinstance(r, dict) and r.get("_id") in want}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def episode(script=None, text=None, sound=None, sheets=None, *, sheet_column: str = "_soundCueSheetID") -> dict:
|
|
55
|
+
"""What the index reads of one episode: {"commands", "text", "sound", "sheets"}, each None when that part is
|
|
56
|
+
not given, or {"why": ...} when the script is not an episode script. `script`: the MonoBehaviour's fields (a
|
|
57
|
+
dict) or its JSON bytes; the shards: TextAsset bytes. Of the shards only the rows the voice commands name are
|
|
58
|
+
kept (texts of their lines and speakers, their sounds, the sheets of those sounds)."""
|
|
59
|
+
if isinstance(script, (bytes, bytearray, memoryview)):
|
|
60
|
+
try:
|
|
61
|
+
script = json.loads(bytes(script).decode("utf-8"))
|
|
62
|
+
except (UnicodeDecodeError, ValueError) as e:
|
|
63
|
+
return {"why": f"the script is not JSON: {e}"}
|
|
64
|
+
out: dict = {"commands": None, "text": None, "sound": None, "sheets": None}
|
|
65
|
+
try:
|
|
66
|
+
if script is not None:
|
|
67
|
+
out["commands"] = voice_commands(script)
|
|
68
|
+
cmds = out["commands"] or []
|
|
69
|
+
if text is not None:
|
|
70
|
+
ids = [c.get("AdvTextID") for c in cmds] + [t for c in cmds for t in c.get("TargetTextIDs") or ()]
|
|
71
|
+
out["text"] = _by_id(shard_rows(text), ids)
|
|
72
|
+
if sound is not None:
|
|
73
|
+
out["sound"] = _by_id(shard_rows(sound), [v for c in cmds for v in c.get("VoiceIDs") or ()])
|
|
74
|
+
if sheets is not None:
|
|
75
|
+
used = [r.get(sheet_column) for r in (out["sound"] or {}).values()]
|
|
76
|
+
out["sheets"] = _by_id(shard_rows(sheets), used)
|
|
77
|
+
except StoryError as e:
|
|
78
|
+
return {"why": str(e)}
|
|
79
|
+
return out
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def load_episode(cat, keys: dict) -> dict:
|
|
83
|
+
"""The parts of one episode read from the catalog (`keys`: {part: catalog key}, PARTS; a key the catalog lacks
|
|
84
|
+
gives no part), as `episode` takes them."""
|
|
85
|
+
from . import unity
|
|
86
|
+
got: dict = {}
|
|
87
|
+
for part, key in keys.items():
|
|
88
|
+
if not cat.has(key):
|
|
89
|
+
continue
|
|
90
|
+
for p in cat.fetch_key(key):
|
|
91
|
+
env = unity.load(p)
|
|
92
|
+
if part == "script":
|
|
93
|
+
tt = unity.first_mono(env, must_have="Collection")
|
|
94
|
+
if tt is not None:
|
|
95
|
+
got[part] = tt
|
|
96
|
+
break
|
|
97
|
+
else:
|
|
98
|
+
ta = unity.textassets(env)
|
|
99
|
+
if ta:
|
|
100
|
+
got[part] = next(iter(ta.values())).encode("utf-8", "surrogateescape")
|
|
101
|
+
break
|
|
102
|
+
return got
|
nnnotes/textstyle.py
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"""TextMesh Pro text layout and style as plain values (shared by the UI exports).
|
|
2
|
+
|
|
3
|
+
A text record describes what a TMP text draws with any font: its layout settings (size, auto-size range,
|
|
4
|
+
alignment, wrapping, overflow, spacing, margins), its colours and style flags, and the look of its TMP
|
|
5
|
+
distance-field material turned into face / outline / underlay values in em. `fontRole` names which of the
|
|
6
|
+
game's localized font slots the text uses, so a renderer maps roles to fonts of its own.
|
|
7
|
+
|
|
8
|
+
language_fonts Fwk.Localization.LocalizeManager font names per LanguageMode (level0)
|
|
9
|
+
localize_text LocalizeText.Init / OnFontChanged: the font asset, material and line spacing a TMP text ends up
|
|
10
|
+
with in a language
|
|
11
|
+
|
|
12
|
+
The language is a Fwk.Localization.LanguageMode (`mode`), the value languages.mode gives for a catalog language code;
|
|
13
|
+
the callers take it from the client language they export (`[catalog] language`).
|
|
14
|
+
font_role the role of a font asset: its slot in LocalizeManager's font name list
|
|
15
|
+
material_style TMP distance-field material properties -> face / outline / underlay values in em
|
|
16
|
+
text_style one TMP text component -> its text record
|
|
17
|
+
TextStyles text records of an exporter's TMP texts (reads the localized materials and face info)
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import struct
|
|
22
|
+
|
|
23
|
+
from .export import Exporter
|
|
24
|
+
from .languages import LANGUAGES
|
|
25
|
+
from .player import PlayerData
|
|
26
|
+
|
|
27
|
+
FONT_PREFIX = "EmbFont/"
|
|
28
|
+
|
|
29
|
+
# Fwk.Localization.LanguageMode / language field names of the text tables.
|
|
30
|
+
JAPANESE_MODE = 0
|
|
31
|
+
LANGUAGE_FIELDS = {mode: column[1:] for mode, column in LANGUAGES.values()} # LanguageMode -> text field
|
|
32
|
+
# LocalizeManager.ApplyLanguageLineSpacing: lineSpacing per LanguageMode
|
|
33
|
+
# (immediates in code, not serialized data).
|
|
34
|
+
LANGUAGE_LINE_SPACING = {0: 0.0, 1: -100.0, 2: 5.0, 3: 5.0, 4: 5.0}
|
|
35
|
+
|
|
36
|
+
# Roles of LocalizeManager's font slots (_fontNames index): the main text face and the Latin / numeral face.
|
|
37
|
+
FONT_ROLES = ("primary", "number")
|
|
38
|
+
|
|
39
|
+
# TMPro enums (flags for FontStyles).
|
|
40
|
+
FONT_STYLES = {1: "bold", 2: "italic", 4: "underline", 8: "lowerCase", 16: "upperCase", 32: "smallCaps",
|
|
41
|
+
64: "strikethrough", 128: "superscript", 256: "subscript", 512: "highlight"}
|
|
42
|
+
HORIZONTAL_ALIGNMENT = {1: "left", 2: "center", 4: "right", 8: "justified", 16: "flush", 32: "geometry"}
|
|
43
|
+
VERTICAL_ALIGNMENT = {256: "top", 512: "middle", 1024: "bottom", 2048: "baseline", 4096: "geometry",
|
|
44
|
+
8192: "capline"}
|
|
45
|
+
WRAPPING = {0: "noWrap", 1: "normal", 2: "preserveWhitespace", 3: "preserveWhitespaceNoWrap"}
|
|
46
|
+
OVERFLOW = {0: "overflow", 1: "ellipsis", 2: "masking", 3: "truncate", 4: "scrollRect", 5: "page", 6: "linked"}
|
|
47
|
+
COLOR_MODE = {0: "single", 1: "horizontalGradient", 2: "verticalGradient", 3: "fourCornersGradient"}
|
|
48
|
+
KERN = 0x6B65726E # OpenType feature tag "kern" (m_ActiveFontFeatures)
|
|
49
|
+
|
|
50
|
+
# Keywords of the TMP distance-field shaders the style values cover.
|
|
51
|
+
STYLE_KEYWORDS = {"OUTLINE_ON", "UNDERLAY_ON", "UNDERLAY_INNER", "RATIOS_OFF"}
|
|
52
|
+
CLAMP = 1.0 # ShaderUtilities.m_clamp
|
|
53
|
+
|
|
54
|
+
UNITS = {
|
|
55
|
+
"fontSize": "canvas units (fontSize, autoSize min / max)",
|
|
56
|
+
"spacing": "characterSpacing, wordSpacing, lineSpacing, paragraphSpacing: 1/100 em, added to the advance / "
|
|
57
|
+
"line pitch as TMP does",
|
|
58
|
+
"em": "*Em values: fraction of the font size the text is drawn at",
|
|
59
|
+
"offsetEm": "[x, y], +x right, +y down",
|
|
60
|
+
"color": "{r, g, b, a} as serialized; fill = color x face.color",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class _Reader:
|
|
65
|
+
"""Little-endian serialized-field reader with 4-byte alignment."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, raw: bytes, pos: int = 0):
|
|
68
|
+
self.raw, self.pos = raw, pos
|
|
69
|
+
|
|
70
|
+
def align(self):
|
|
71
|
+
self.pos = (self.pos + 3) & ~3
|
|
72
|
+
|
|
73
|
+
def i32(self) -> int:
|
|
74
|
+
v = struct.unpack_from("<i", self.raw, self.pos)[0]
|
|
75
|
+
self.pos += 4
|
|
76
|
+
return v
|
|
77
|
+
|
|
78
|
+
def i64(self) -> int:
|
|
79
|
+
v = struct.unpack_from("<q", self.raw, self.pos)[0]
|
|
80
|
+
self.pos += 8
|
|
81
|
+
return v
|
|
82
|
+
|
|
83
|
+
def u8(self) -> int:
|
|
84
|
+
v = self.raw[self.pos]
|
|
85
|
+
self.pos += 1
|
|
86
|
+
return v
|
|
87
|
+
|
|
88
|
+
def string(self) -> str:
|
|
89
|
+
n = self.i32()
|
|
90
|
+
s = self.raw[self.pos:self.pos + n].decode("utf-8")
|
|
91
|
+
self.pos += n
|
|
92
|
+
self.align()
|
|
93
|
+
return s
|
|
94
|
+
|
|
95
|
+
def strings(self) -> list[str]:
|
|
96
|
+
return [self.string() for _ in range(self.i32())]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def language_fonts(player: PlayerData) -> dict:
|
|
100
|
+
"""Fwk.Localization.LocalizeManager (level0): font names per LanguageMode.
|
|
101
|
+
|
|
102
|
+
Read field by field: the generated typetree for this class omits the
|
|
103
|
+
alignment after `_dontDestroyOnload`, so the serialized bytes are parsed
|
|
104
|
+
directly (layout: m_GameObject, m_Enabled, m_Script, m_Name,
|
|
105
|
+
_dontDestroyOnload, _languageDataList[{_languageMode, _fontNames[],
|
|
106
|
+
_additionalFontNames[]}], _materialTypeList[]).
|
|
107
|
+
"""
|
|
108
|
+
hits = [o for o in player._by_type.get("MonoBehaviour", [])
|
|
109
|
+
if player.script(o)[1:] == ("Fwk.Localization", "LocalizeManager")]
|
|
110
|
+
if len(hits) != 1:
|
|
111
|
+
raise RuntimeError(f"LocalizeManager: {len(hits)} objects")
|
|
112
|
+
r = _Reader(hits[0].get_raw_data())
|
|
113
|
+
r.i32(); r.i64() # m_GameObject
|
|
114
|
+
r.u8(); r.align() # m_Enabled
|
|
115
|
+
r.i32(); r.i64() # m_Script
|
|
116
|
+
r.string() # m_Name
|
|
117
|
+
r.u8(); r.align() # _dontDestroyOnload
|
|
118
|
+
langs = {}
|
|
119
|
+
for _ in range(r.i32()):
|
|
120
|
+
mode = r.i32()
|
|
121
|
+
langs[mode] = {"fontNames": r.strings(), "additionalFontNames": r.strings()}
|
|
122
|
+
materials = r.strings()
|
|
123
|
+
if r.pos != len(r.raw):
|
|
124
|
+
raise RuntimeError(f"LocalizeManager: {len(r.raw) - r.pos} trailing bytes")
|
|
125
|
+
return {"languages": langs, "materialTypes": materials}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# --------------------------------------------------------------------------
|
|
129
|
+
# localized font / material of a TMP text (LocalizeText)
|
|
130
|
+
# --------------------------------------------------------------------------
|
|
131
|
+
def font_swap(lang: dict, mode: int) -> dict:
|
|
132
|
+
"""LocalizeManager.TryGetFontIndex + LocalizeText.OnFontChanged: the index of a font
|
|
133
|
+
asset name in the Japanese lookup table (Japanese LanguageData font names + " SDF") selects fonts[index] of
|
|
134
|
+
the language `mode`."""
|
|
135
|
+
ja = lang["languages"][JAPANESE_MODE]["fontNames"]
|
|
136
|
+
fonts = lang["languages"][mode]["fontNames"]
|
|
137
|
+
return {f"{j} SDF": f"{f} SDF" for j, f in zip(ja, fonts)}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def material_type(material_name: str | None) -> str:
|
|
141
|
+
"""LocalizeManager.GetMaterialType: "Default", or the part after " - " without " (Instance)".
|
|
142
|
+
(Its ".mat" branch rewrites the literal "Default", a no-op.)"""
|
|
143
|
+
if material_name is None or " - " not in material_name:
|
|
144
|
+
return "Default"
|
|
145
|
+
return material_name[material_name.index(" - "):].replace(" - ", "").replace(" (Instance)", "")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def localize_text(path: str, font_asset: str, material: str, swap: dict, lang: dict, mode: int) -> dict:
|
|
149
|
+
"""LocalizeText.Init -> OnFontChanged for a text with _localizeEnabled: font =
|
|
150
|
+
fonts[TryGetFontIndex(font)], material = GetFontMaterial(index, GetMaterialType(sharedMaterial.name))
|
|
151
|
+
("<font> - <type>", key EmbFont/<font>/...), lineSpacing = ApplyLanguageLineSpacing."""
|
|
152
|
+
if font_asset not in swap:
|
|
153
|
+
raise RuntimeError(f"{path}: font {font_asset} not in the Japanese font lookup table")
|
|
154
|
+
font_name = swap[font_asset]
|
|
155
|
+
mat_type = material_type(material)
|
|
156
|
+
if mat_type not in lang["materialTypes"]:
|
|
157
|
+
# ExistsFontMaterial false -> GetDefaultFontMaterial; the loaded handle set is not modelled
|
|
158
|
+
raise NotImplementedError(f"{path}: material type {mat_type} not in LocalizeManager._materialTypeList")
|
|
159
|
+
return {"fontAsset": font_name, "material": f"{font_name[:-4]} - {mat_type}",
|
|
160
|
+
"lineSpacing": LANGUAGE_LINE_SPACING[mode]}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def font_role(font_asset: str, lang: dict) -> str:
|
|
164
|
+
"""The role of a serialized font asset: its slot in the Japanese font name list (the slot LocalizeText
|
|
165
|
+
swaps to the font of the text's language), named by FONT_ROLES."""
|
|
166
|
+
ja = [f"{j} SDF" for j in lang["languages"][JAPANESE_MODE]["fontNames"]]
|
|
167
|
+
if font_asset not in ja:
|
|
168
|
+
raise RuntimeError(f"font {font_asset} not in the Japanese font lookup table")
|
|
169
|
+
i = ja.index(font_asset)
|
|
170
|
+
return FONT_ROLES[i] if i < len(FONT_ROLES) else f"font{i}"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def font_dir(font_asset: str) -> str:
|
|
174
|
+
"""Addressable folder of a font asset and its materials: EmbFont/<font name without " SDF">/."""
|
|
175
|
+
return f"{FONT_PREFIX}{font_asset[:-4] if font_asset.endswith(' SDF') else font_asset}/"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# --------------------------------------------------------------------------
|
|
179
|
+
# material -> style values
|
|
180
|
+
# --------------------------------------------------------------------------
|
|
181
|
+
def scale_ratios(floats: dict, keywords) -> tuple[float, float]:
|
|
182
|
+
"""ShaderUtilities.UpdateShaderRatios (run by TMP before drawing): _ScaleRatioA (face / outline) and
|
|
183
|
+
_ScaleRatioC (underlay)."""
|
|
184
|
+
if "RATIOS_OFF" in keywords:
|
|
185
|
+
return 1.0, 1.0
|
|
186
|
+
gs = floats["_GradientScale"]
|
|
187
|
+
dilate = floats.get("_FaceDilate", 0.0)
|
|
188
|
+
weight = max(floats.get("_WeightNormal", 0.0), floats.get("_WeightBold", 0.0)) / 4.0
|
|
189
|
+
t = max(1.0, weight + dilate + floats.get("_OutlineWidth", 0.0) + floats.get("_OutlineSoftness", 0.0))
|
|
190
|
+
a = (gs - CLAMP) / (gs * t)
|
|
191
|
+
c = 1.0
|
|
192
|
+
if "_UnderlayOffsetX" in floats:
|
|
193
|
+
rng = (weight + dilate) * (gs - CLAMP)
|
|
194
|
+
t = max(1.0, max(abs(floats["_UnderlayOffsetX"]), abs(floats.get("_UnderlayOffsetY", 0.0)))
|
|
195
|
+
+ floats.get("_UnderlayDilate", 0.0) + floats.get("_UnderlaySoftness", 0.0))
|
|
196
|
+
c = max(0.0, gs - CLAMP - rng) / (gs * t)
|
|
197
|
+
return a, c
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def material_style(material: dict, point_size: float, face_scale: float = 1.0) -> dict:
|
|
201
|
+
"""The look a TMP distance-field material gives a text, in em of the drawn font size.
|
|
202
|
+
|
|
203
|
+
Distance-field values are texels of the font atlas (rendered at `point_size` px per em, times the face info
|
|
204
|
+
scale), scaled as TextMeshPro/Mobile/Distance Field does:
|
|
205
|
+
face edge moved outwards by (weight / 4 + _FaceDilate) * ratioA * _GradientScale texels, weight =
|
|
206
|
+
_WeightNormal (_WeightBold for bold text);
|
|
207
|
+
outline band +-_OutlineWidth * ratioA * _GradientScale texels around the face edge (OUTLINE_ON);
|
|
208
|
+
edge ramp width 2 * _OutlineSoftness * ratioA * _GradientScale texels;
|
|
209
|
+
underlay (UNDERLAY_ON / UNDERLAY_INNER) = the face shape moved by _UnderlayOffset * ratioC * _GradientScale
|
|
210
|
+
texels (+y up in the atlas), grown by _UnderlayDilate * ratioC * _GradientScale texels, ramp width
|
|
211
|
+
2 * _UnderlaySoftness * ratioC * _GradientScale texels, drawn behind the face (inside it for
|
|
212
|
+
UNDERLAY_INNER).
|
|
213
|
+
"""
|
|
214
|
+
f, c, kw = material["floats"], material["colors"], set(material["keywords"])
|
|
215
|
+
extra = kw - STYLE_KEYWORDS
|
|
216
|
+
if extra:
|
|
217
|
+
raise NotImplementedError(f"{material['material']}: style keywords {sorted(extra)}")
|
|
218
|
+
if "_GradientScale" not in f:
|
|
219
|
+
raise NotImplementedError(f"{material['material']}: not a distance-field text material")
|
|
220
|
+
ra, rc = scale_ratios(f, kw)
|
|
221
|
+
em = f["_GradientScale"] * face_scale / point_size # em per unit of SDF distance
|
|
222
|
+
|
|
223
|
+
def r6(v: float) -> float:
|
|
224
|
+
return round(v, 6)
|
|
225
|
+
|
|
226
|
+
face = {"color": c.get("_FaceColor", {"r": 1.0, "g": 1.0, "b": 1.0, "a": 1.0}),
|
|
227
|
+
"dilateEm": r6((f.get("_WeightNormal", 0.0) / 4 + f.get("_FaceDilate", 0.0)) * ra * em),
|
|
228
|
+
"boldDilateEm": r6((f.get("_WeightBold", 0.0) / 4 + f.get("_FaceDilate", 0.0)) * ra * em),
|
|
229
|
+
"softnessEm": r6(2 * f.get("_OutlineSoftness", 0.0) * ra * em)}
|
|
230
|
+
outline = None
|
|
231
|
+
if "OUTLINE_ON" in kw:
|
|
232
|
+
outline = {"color": c["_OutlineColor"], "widthEm": r6(f["_OutlineWidth"] * ra * em),
|
|
233
|
+
"softnessEm": face["softnessEm"]}
|
|
234
|
+
underlay = None
|
|
235
|
+
if kw & {"UNDERLAY_ON", "UNDERLAY_INNER"}:
|
|
236
|
+
underlay = {"color": c["_UnderlayColor"],
|
|
237
|
+
"offsetEm": [r6(f["_UnderlayOffsetX"] * rc * em), r6(-f["_UnderlayOffsetY"] * rc * em)],
|
|
238
|
+
"dilateEm": r6(f["_UnderlayDilate"] * rc * em),
|
|
239
|
+
"softnessEm": r6(2 * f["_UnderlaySoftness"] * rc * em),
|
|
240
|
+
"inner": "UNDERLAY_INNER" in kw}
|
|
241
|
+
return {"face": face, "outline": outline, "underlay": underlay}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def face_metrics(face_info: dict, spacing: dict | None = None) -> dict:
|
|
245
|
+
"""Line metrics of a font asset in em (TMP line pitch = lineHeight + lineSpacing / 100 em), and the
|
|
246
|
+
spacing it adds to every advance (normalSpacingOffset; boldSpacing for bold text) in 1/100 em."""
|
|
247
|
+
k = face_info["m_Scale"] / face_info["m_PointSize"]
|
|
248
|
+
out = {"lineHeightEm": round(face_info["m_LineHeight"] * k, 6),
|
|
249
|
+
"ascentEm": round(face_info["m_AscentLine"] * k, 6),
|
|
250
|
+
"descentEm": round(face_info["m_DescentLine"] * k, 6)}
|
|
251
|
+
if spacing is not None:
|
|
252
|
+
out["spacingOffset"] = spacing["normalSpacingOffset"]
|
|
253
|
+
out["boldSpacing"] = spacing["boldSpacing"]
|
|
254
|
+
return out
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# --------------------------------------------------------------------------
|
|
258
|
+
# text record
|
|
259
|
+
# --------------------------------------------------------------------------
|
|
260
|
+
def _flags(value: int, names: dict) -> list[str]:
|
|
261
|
+
return [n for bit, n in names.items() if value & bit]
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _enum(value: int, names: dict):
|
|
265
|
+
return names.get(value, value)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def text_style(comp: dict, localize: dict | None, lang: dict, style: dict, mode: int) -> dict:
|
|
269
|
+
"""The text record of one TMP text component (serialized fields, as in the prefab / scene node) with its
|
|
270
|
+
LocalizeText component (or None) and the material_style of the material it draws with, in the language
|
|
271
|
+
`mode`."""
|
|
272
|
+
localized = bool(localize and localize["m_Enabled"] and localize["_localizeEnabled"])
|
|
273
|
+
key = (localize or {}).get("_masterTextID")
|
|
274
|
+
m = comp["m_margin"]
|
|
275
|
+
rec = {
|
|
276
|
+
"class": comp.get("class", comp.get("type")),
|
|
277
|
+
"enabled": bool(comp["m_Enabled"]),
|
|
278
|
+
"fontRole": font_role(comp["m_fontAsset"]["name"], lang),
|
|
279
|
+
"materialType": material_type(comp["m_sharedMaterial"]["material"]),
|
|
280
|
+
"localized": localized,
|
|
281
|
+
"textKey": key if key not in (None, "", "0") else None,
|
|
282
|
+
"text": comp["m_text"],
|
|
283
|
+
"richText": bool(comp["m_isRichText"]),
|
|
284
|
+
"parseControlCharacters": bool(comp["m_parseCtrlCharacters"]),
|
|
285
|
+
"fontSize": comp["m_fontSize"],
|
|
286
|
+
"autoSize": {"enabled": bool(comp["m_enableAutoSizing"]), "min": comp["m_fontSizeMin"],
|
|
287
|
+
"max": comp["m_fontSizeMax"], "maxCharWidthAdjust": comp["m_charWidthMaxAdj"],
|
|
288
|
+
"maxLineSpacingAdjust": comp["m_lineSpacingMax"]},
|
|
289
|
+
"fontStyle": _flags(comp["m_fontStyle"], FONT_STYLES),
|
|
290
|
+
"fontWeight": comp["m_fontWeight"],
|
|
291
|
+
"alignment": {"horizontal": _enum(comp["m_HorizontalAlignment"], HORIZONTAL_ALIGNMENT),
|
|
292
|
+
"vertical": _enum(comp["m_VerticalAlignment"], VERTICAL_ALIGNMENT)},
|
|
293
|
+
"wrapping": _enum(comp["m_TextWrappingMode"], WRAPPING),
|
|
294
|
+
"overflow": _enum(comp["m_overflowMode"], OVERFLOW),
|
|
295
|
+
"margin": {"left": m["x"], "top": m["y"], "right": m["z"], "bottom": m["w"]},
|
|
296
|
+
"lineSpacing": {"serialized": comp["m_lineSpacing"],
|
|
297
|
+
"applied": LANGUAGE_LINE_SPACING[mode] if localized else comp["m_lineSpacing"],
|
|
298
|
+
"byLanguage": ({LANGUAGE_FIELDS[k]: v for k, v in LANGUAGE_LINE_SPACING.items()}
|
|
299
|
+
if localized else None)},
|
|
300
|
+
"paragraphSpacing": comp["m_paragraphSpacing"],
|
|
301
|
+
"characterSpacing": comp["m_characterSpacing"],
|
|
302
|
+
"wordSpacing": comp["m_wordSpacing"],
|
|
303
|
+
"characterHorizontalScale": comp.get("m_characterHorizontalScale", 1.0),
|
|
304
|
+
"kerning": bool(comp["m_enableKerning"]) or KERN in comp.get("m_ActiveFontFeatures", []),
|
|
305
|
+
"rightToLeft": bool(comp["m_isRightToLeft"]),
|
|
306
|
+
"orthographic": bool(comp["m_isOrthographic"]),
|
|
307
|
+
"color": comp["m_fontColor"],
|
|
308
|
+
"colorMode": _enum(comp["m_colorMode"], COLOR_MODE),
|
|
309
|
+
"colorGradient": comp["m_fontColorGradient"] if comp["m_enableVertexGradient"] else None,
|
|
310
|
+
"overrideHtmlColors": bool(comp["m_overrideHtmlColors"]),
|
|
311
|
+
**style,
|
|
312
|
+
}
|
|
313
|
+
return rec
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
class TextStyles:
|
|
317
|
+
"""Text records of TMP texts read through an Exporter: the material each text draws with in the language
|
|
318
|
+
`mode` (LocalizeText) and the face info of its font asset; no font data is kept."""
|
|
319
|
+
|
|
320
|
+
def __init__(self, ex: Exporter, player: PlayerData, mode: int):
|
|
321
|
+
self.ex, self.mode = ex, mode
|
|
322
|
+
self.lang = language_fonts(player)
|
|
323
|
+
self.swap = font_swap(self.lang, mode)
|
|
324
|
+
self.roles: dict[str, dict] = {}
|
|
325
|
+
self._styles: dict[tuple, dict] = {}
|
|
326
|
+
self._faces: dict[str, tuple] = {}
|
|
327
|
+
|
|
328
|
+
def _face(self, font_asset: str) -> tuple[dict, dict]:
|
|
329
|
+
if font_asset not in self._faces:
|
|
330
|
+
tt = self.ex.key_object(font_dir(font_asset) + font_asset).read_typetree()
|
|
331
|
+
self._faces[font_asset] = (tt["m_FaceInfo"], {k: tt[k] for k in ("normalSpacingOffset", "boldSpacing")})
|
|
332
|
+
return self._faces[font_asset]
|
|
333
|
+
|
|
334
|
+
def record(self, path: str, comp: dict, localize: dict | None) -> dict:
|
|
335
|
+
"""The text record of `comp` (TMP text component) with its LocalizeText component `localize`."""
|
|
336
|
+
fa, mat = comp["m_fontAsset"]["name"], comp["m_sharedMaterial"]["material"]
|
|
337
|
+
if localize and localize["m_Enabled"] and localize["_localizeEnabled"]:
|
|
338
|
+
loc = localize_text(path, fa, mat, self.swap, self.lang, self.mode)
|
|
339
|
+
fa, mat = loc["fontAsset"], loc["material"]
|
|
340
|
+
if (fa, mat) not in self._styles:
|
|
341
|
+
key = font_dir(fa) + mat
|
|
342
|
+
if not self.ex.cat.has(key):
|
|
343
|
+
raise KeyError(f"material key {key}")
|
|
344
|
+
face, extra = self._face(fa)
|
|
345
|
+
self._styles[(fa, mat)] = material_style(self.ex.material(self.ex.key_object(key)),
|
|
346
|
+
face["m_PointSize"], face["m_Scale"])
|
|
347
|
+
rec = text_style(comp, localize, self.lang, self._styles[(fa, mat)], self.mode)
|
|
348
|
+
if rec["fontRole"] not in self.roles:
|
|
349
|
+
face, extra = self._face(fa)
|
|
350
|
+
self.roles[rec["fontRole"]] = face_metrics(face, extra)
|
|
351
|
+
return rec
|
|
352
|
+
|
|
353
|
+
def summary(self) -> dict:
|
|
354
|
+
"""The document-level part: the language's text field, units and the line metrics of each role used."""
|
|
355
|
+
return {"language": LANGUAGE_FIELDS[self.mode], "units": UNITS, "roles": self.roles}
|