nnnotes 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. nnnotes/__init__.py +7 -0
  2. nnnotes/__main__.py +4 -0
  3. nnnotes/acb.py +190 -0
  4. nnnotes/addressables.py +413 -0
  5. nnnotes/adv.py +243 -0
  6. nnnotes/advcommand.py +29 -0
  7. nnnotes/advmedia.py +178 -0
  8. nnnotes/advscene.py +113 -0
  9. nnnotes/advui.py +1154 -0
  10. nnnotes/advvideo.py +684 -0
  11. nnnotes/atoms/__init__.py +151 -0
  12. nnnotes/atoms/astc.py +52 -0
  13. nnnotes/atoms/gltf.py +290 -0
  14. nnnotes/atoms/mesh.py +289 -0
  15. nnnotes/atoms/png.py +28 -0
  16. nnnotes/atoms/reader.py +269 -0
  17. nnnotes/atoms/sniff.py +198 -0
  18. nnnotes/atoms/sprite.py +236 -0
  19. nnnotes/atoms/texture.py +80 -0
  20. nnnotes/cache.py +289 -0
  21. nnnotes/catalog.py +373 -0
  22. nnnotes/catalogdb.py +457 -0
  23. nnnotes/census.py +276 -0
  24. nnnotes/cli.py +744 -0
  25. nnnotes/cli_assets.py +1539 -0
  26. nnnotes/config.py +238 -0
  27. nnnotes/contract.py +497 -0
  28. nnnotes/cri.py +371 -0
  29. nnnotes/crikey.py +71 -0
  30. nnnotes/crilips.py +265 -0
  31. nnnotes/cristages.py +558 -0
  32. nnnotes/deckdata.py +462 -0
  33. nnnotes/export.py +1462 -0
  34. nnnotes/gameapi.py +318 -0
  35. nnnotes/jsonio.py +98 -0
  36. nnnotes/languages.py +44 -0
  37. nnnotes/layout.py +596 -0
  38. nnnotes/link.py +418 -0
  39. nnnotes/live.py +115 -0
  40. nnnotes/live2d.py +235 -0
  41. nnnotes/liveaudio.py +331 -0
  42. nnnotes/livenotes.py +291 -0
  43. nnnotes/liveoptions.py +250 -0
  44. nnnotes/livescene.py +450 -0
  45. nnnotes/liveui.py +328 -0
  46. nnnotes/master.py +251 -0
  47. nnnotes/modelstages.py +424 -0
  48. nnnotes/motion.py +131 -0
  49. nnnotes/objexport.py +1564 -0
  50. nnnotes/orchestrate.py +630 -0
  51. nnnotes/plan.py +218 -0
  52. nnnotes/player.py +392 -0
  53. nnnotes/room.py +391 -0
  54. nnnotes/score.py +1139 -0
  55. nnnotes/shader.py +217 -0
  56. nnnotes/spot.py +201 -0
  57. nnnotes/stages.py +285 -0
  58. nnnotes/store.py +518 -0
  59. nnnotes/story.py +142 -0
  60. nnnotes/storyfonts.py +1258 -0
  61. nnnotes/storyhost.py +1190 -0
  62. nnnotes/storysite.py +662 -0
  63. nnnotes/storyvoices.py +102 -0
  64. nnnotes/textstyle.py +355 -0
  65. nnnotes/tmpfont.py +636 -0
  66. nnnotes/typetrees/6000.3.12f1.json +19 -0
  67. nnnotes/unity.py +368 -0
  68. nnnotes/viewrules.json +441 -0
  69. nnnotes/views.py +966 -0
  70. nnnotes/voices.py +1363 -0
  71. nnnotes/web.py +1624 -0
  72. nnnotes/webaudio.py +14 -0
  73. nnnotes/webmodel.py +397 -0
  74. nnnotes-0.1.0.dist-info/METADATA +200 -0
  75. nnnotes-0.1.0.dist-info/RECORD +79 -0
  76. nnnotes-0.1.0.dist-info/WHEEL +5 -0
  77. nnnotes-0.1.0.dist-info/entry_points.txt +2 -0
  78. nnnotes-0.1.0.dist-info/licenses/LICENSE +21 -0
  79. nnnotes-0.1.0.dist-info/top_level.txt +1 -0
nnnotes/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}