diff --git a/src/nnnotes/content/advui.py b/src/nnnotes/content/advui.py index cca94e5..7bbde5f 100644 --- a/src/nnnotes/content/advui.py +++ b/src/nnnotes/content/advui.py @@ -729,7 +729,6 @@ def records(prefab_nodes: list, keep_text: bool) -> list: # -- glyph blocks ------------------------------------------------------------- if game: font_out = tmpfont.export_fonts( - ex, font_doc["fonts"], font_doc["primaries"], font_doc["needed"], diff --git a/src/nnnotes/content/liveui.py b/src/nnnotes/content/liveui.py index 17e2739..9b35261 100644 --- a/src/nnnotes/content/liveui.py +++ b/src/nnnotes/content/liveui.py @@ -164,7 +164,6 @@ def _game_fonts(ex: Exporter, fontset, texts: dict, chars_by_font: dict, packer: ex, fontset, [t["localized"] for t in texts.values()], primaries, needed, runtime_units ) font_out = tmpfont.export_fonts( - ex, fontset, primaries, needed, diff --git a/src/nnnotes/content/tmpassets.py b/src/nnnotes/content/tmpassets.py new file mode 100644 index 0000000..aff833e --- /dev/null +++ b/src/nnnotes/content/tmpassets.py @@ -0,0 +1,299 @@ +"""TextMesh Pro font assets read from an object table (content/prefabs.Objects). + +The records tmpfont.py reads through an Exporter, read from the object layer instead: a font asset's fields from its +`json`, its source font file from its `font` file, its atlas pages from their `image` files (PNG of the decoded image), +its materials from their `json` (the shader by name, the textures as texture records). + + Texture a Texture2D of the table: its record and its RGBA texels + Font a TMP_FontAsset of the table (the fields tmpfont.Font reads) + FontSet the font assets reached from a font asset, with the TMP character lookup of tmpfont.FontLookup + build_font_settings the font settings table (fontsettings.FORMAT) read from the table + missing_glyph_character + the game's TMP Settings resource (tmpfont.missing_glyph_character) + +The glyph tables and runtime glyphs are the tmpfont functions (export_fonts, runtime_glyphs), which take an +export.MemoryPacker to hold the packed pages in memory. Reading a font needs the `fonts` extra (tmpfont.require_extra). +""" + +from __future__ import annotations + +import copy +import hashlib +import io +from collections.abc import Callable +from typing import cast + +import numpy as np +from PIL import Image + +from ..unity.export import texel_rows +from ..unity.fontsettings import FONT_CLASS, font_dir, font_key +from ..unity.ids import parse_object_id +from ..unity.tmpfont import ( + MISSING_GLYPH_CHARACTER, + BuiltFontSettings, + FontAsset, + FontLookup, + font_cmap, + font_document, + font_record, + load_listed, +) +from .prefabs import Objects, key_object, ref_id + + +def _ref(v) -> str | None: + """The object id a reference value of the table names (None for null); a reference the table does not resolve + raises.""" + if v is None: + return None + oid = ref_id(v) + if oid is None: + raise ValueError(f"reference {v!r} does not resolve in the object table") + return oid + + +class _Keys: + """The catalog's `has` (fontsettings.font_key) over the keys of an object table.""" + + def __init__(self, objects: Objects): + self.objects = objects + + def has(self, key: str) -> bool: + try: + self.objects.key(key) + except KeyError: + return False + return True + + +class Texture: + """A Texture2D of an object table: its record (name, size, format, mip count and sampler settings, from its + `json`) and its RGBA texels, bottom row first, decoded from its `image` file.""" + + def __init__(self, objects: Objects, oid: str): + self.oid = oid + self._objects = objects + tt = objects.json(oid) + self.key: tuple[str, int] = parse_object_id(oid) + self.name: str = tt["m_Name"] + self.width: int = tt["m_Width"] + self.height: int = tt["m_Height"] + self.format: int = tt["m_TextureFormat"] + self.mip_count: int = tt.get("m_MipCount", 1) + self.settings: dict = tt["m_TextureSettings"] + self._pixels: np.ndarray | None = None + + @property + def pixels(self) -> np.ndarray: + """The texels as RGBA, bottom row first (read-only), decoded once.""" + if self._pixels is None: + ext = self._objects.info(self.oid)["files"]["image"]["ext"] + if ext != "png": + raise NotImplementedError(f"texture {self.name}: image file {ext}") + img = Image.open(io.BytesIO(self._objects.bytes(self.oid, "image"))) + arr = texel_rows(img, self.format, self.width, self.height, self.name) + arr.setflags(write=False) + self._pixels = arr + return self._pixels + + def record(self) -> dict: + """The record of a material's texture reference (export.Exporter.texture in deferred mode).""" + return { + "textureRef": self.oid, + "name": self.name, + "width": self.width, + "height": self.height, + "format": self.format, + "mipCount": self.mip_count, + "settings": self.settings, + } + + +class Font: + """A TMP_FontAsset of an object table: the fields tmpfont.Font reads, its atlas pages (Texture, None for a missing + page), the bytes and character map of its source font file, and its default material.""" + + def __init__(self, fonts: FontSet, oid: str): + objects = fonts.objects + tt = objects.json(oid) + self.oid = oid + self._fonts = fonts + self.tt = tt + self.name: str = tt["m_Name"] + self.chars = {c["m_Unicode"]: c for c in tt["m_CharacterTable"]} + self.glyphs = {g["m_Index"]: g for g in tt["m_GlyphTable"]} + self.dynamic = tt["m_AtlasPopulationMode"] == 1 + self.atlases: list[Texture | None] = [] + for p in tt["m_AtlasTextures"]: + page = _ref(p) + self.atlases.append(None if page is None else fonts.texture(page)) + material = _ref(tt["m_Material"]) + if material is None: + raise KeyError(f"{self.name}: a required reference is null (m_Material)") + self.material_oid: str = material + self.fallback_oids: list[str | None] = [_ref(p) for p in tt["m_FallbackFontAssetTable"]] + if any(_ref(p["regularTypeface"]) or _ref(p["italicTypeface"]) for p in tt["m_FontWeightTable"]): + raise NotImplementedError(f"{self.name}: font weight table typefaces") + self.cmap: set[int] = set() + self.variants: dict[tuple[int, int], str] = {} + self.font_data: bytes | None = None + self.source_font: dict | None = None + src = _ref(tt["m_SourceFontFile"]) + if src is not None: + data = objects.bytes(src, "font") + self.cmap, self.variants = font_cmap(data, tt["m_FaceInfo"]["m_FaceIndex"]) + self.font_data = data + self.source_font = { + "name": objects.json(src)["m_Name"], + "bytes": len(data), + "sha256": hashlib.sha256(data).hexdigest(), + } + + def atlas_names(self) -> list[str | None]: + return [None if a is None else a.name for a in self.atlases] + + def atlas_settings(self) -> list[dict]: + return [a.settings for a in self.atlases if a is not None] + + def default_material(self) -> dict: + """The record of the default material (export.Exporter.material).""" + return self._fonts.material(self.material_oid) + + +class FontSet(FontLookup): + """The font assets of an object table reached from a font asset (through their fallback chains), with the TMP + character lookup of FontLookup.""" + + def __init__(self, objects: Objects): + super().__init__() + self.objects = objects + self._by_oid: dict[str, Font] = {} + self._textures: dict[str, Texture] = {} + + def texture(self, oid: str) -> Texture: + if oid not in self._textures: + cls = self.objects.info(oid).get("class") + if cls != "Texture2D": + raise NotImplementedError(f"{oid}: a texture reference to a {cls}") + self._textures[oid] = Texture(self.objects, oid) + return self._textures[oid] + + def by_key(self, key: str) -> Font: + """The font asset of the catalog key `key` (its font folder's TMP_FontAsset).""" + return self.of(key_object(self.objects, key, FONT_CLASS)) + + def loaded(self, name: str) -> Font: + """The font asset `name`, read by `of` or `by_key`.""" + return cast(Font, self.fonts[name]) + + def of(self, oid: str) -> Font: + if oid not in self._by_oid: + f = Font(self, oid) + if f.name in self.fonts: + raise RuntimeError(f"two font assets named {f.name}") + self._by_oid[oid] = f + self.fonts[f.name] = f + self._fallback_names[f.name] = [self.of(fb).name for fb in f.fallback_oids if fb is not None] + return self._by_oid[oid] + + def material(self, oid: str) -> dict: + """The record of the Material `oid` (export.Exporter.material): its shader by name, its textures as texture + records, its other fields as in its `json`.""" + doc = self.objects.json(oid) + shader = _ref(doc["shader"]) + return { + "material": doc["material"], + "shader": None if shader is None else {"shader": self.objects.json(shader)["name"]}, + "keywords": doc["keywords"], + "invalidKeywords": doc["invalidKeywords"], + "renderQueue": doc["renderQueue"], + "tags": doc["tags"], + "disabledPasses": doc["disabledPasses"], + "textures": {k: self._texture_entry(v) for k, v in doc["textures"].items()}, + "ints": doc["ints"], + "floats": doc["floats"], + "colors": doc["colors"], + } + + def _texture_entry(self, v: dict) -> dict: + tex = _ref(v["texture"]) + return { + "texture": None if tex is None else self.texture(tex).record(), + "scale": v["scale"], + "offset": v["offset"], + } + + def named_material(self, font: FontAsset, name: str) -> dict: + """The material `name` of the font asset `font`, at its key in the font's folder (tmpfont.material_by_name).""" + key = font_dir(font.name) + name + if not _Keys(self.objects).has(key): + raise KeyError(f"material key {key}") + return self.material(key_object(self.objects, key, "Material")) + + +class TableFontSettings(BuiltFontSettings): + """FontSettings read from the font assets of an object table (FontSet): each font asset's bundle from + `bundle_of(name)`, the records as tmpfont.AssetFontSettings reads them from the game's assets.""" + + def __init__(self, fonts: FontSet, bundle_of: Callable[[str], str]): + super().__init__() + self._set = fonts + self._keys = _Keys(fonts.objects) + self._bundle_of = bundle_of + + def font(self, name: str) -> dict: + if name not in self.fonts: + if name not in self._set.fonts: + self._set.by_key(font_key(self._keys, name, name)) + f = self._set.loaded(name) + mat = f.default_material() + self.materials.setdefault(mat["material"], mat) + self.material_ids.setdefault(mat["material"], parse_object_id(f.material_oid)[1]) + self.font_ids[name] = parse_object_id(f.oid)[1] + fallbacks = [fb.name for fb in self._set.fallbacks(f)] + self.fonts[name] = font_record(f, mat["material"], fallbacks, self._bundle_of(name)) + return self.fonts[name] + + def has_material(self, font: str, name: str) -> bool: + return name in self.materials or self._keys.has(font_key(self._keys, font, name)) + + def material(self, font: str | None, name: str) -> dict: + if name not in self.materials: + if font is None: + raise KeyError(f"material {name}: not read yet, and no font asset to find it by") + key = font_key(self._keys, font, name) + if not self._keys.has(key): + raise KeyError(f"material key {key}") + oid = key_object(self._set.objects, key, "Material") + self.material_ids[name] = parse_object_id(oid)[1] + self.materials[name] = self._set.material(oid) + return copy.deepcopy(self.materials[name]) + + def where(self) -> str: + return "the font assets of the object table" + + +def build_font_settings(objects: Objects, lang: dict, game_version: str, bundle_of: Callable[[str], str]) -> dict: + """The font settings table (fontsettings.FORMAT) read from the font assets of an object table: the document + tmpfont.build_font_settings gives for the game's assets. `lang` is LocalizeManager's font data + (textstyle.language_fonts), `bundle_of(name)` the bundle that holds the font asset `name`.""" + settings = TableFontSettings(FontSet(objects), bundle_of) + return font_document(settings, load_listed(settings, lang), game_version) + + +def missing_glyph_character(objects: Objects) -> int: + """TMP_Settings.missingGlyphCharacter of the table's TMP Settings resource (tmpfont.missing_glyph_character).""" + hits = [ + o + for o in objects.roots + if o in objects + and objects.info(o).get("name") == "TMP Settings" + and objects.info(o).get("class") == "MonoBehaviour" + ] + if len(hits) != 1: + raise ValueError(f"TMP Settings: {len(hits)} resources in the table, expected one") + tt = objects.json(hits[0]) + if tt["m_fallbackFontAssets"] or _ref(tt["m_defaultFontAsset"]) is not None: + raise NotImplementedError("TMP Settings fallback font assets / default font asset") + return tt["m_missingGlyphCharacter"] or MISSING_GLYPH_CHARACTER diff --git a/src/nnnotes/unity/export.py b/src/nnnotes/unity/export.py index 2f8b1cc..ef961ca 100644 --- a/src/nnnotes/unity/export.py +++ b/src/nnnotes/unity/export.py @@ -129,6 +129,110 @@ def grow_box(x0: float, y0: float, x1: float, y1: float, margin: int) -> tuple[i return (math.floor(x0) - margin, math.floor(y0) - margin, math.ceil(x1) + margin, math.ceil(y1) + margin) +def wrap_mode(mode: int) -> Literal["wrap", "clip"]: + """The np.take mode of a texture's wrap mode (m_WrapU / m_WrapV: 0 repeat, 1 clamp).""" + if mode == 0: + return "wrap" + if mode == 1: + return "clip" + raise NotImplementedError(f"texture wrap mode {mode}") + + +def texel_rows(img, texture_format: int, width: int, height: int, name: str) -> np.ndarray: + """The RGBA texels of a decoded Texture2D image `img` (a PIL image; the texture's format, size and name), bottom + row first. Alpha8 keeps its value in the alpha channel.""" + if texture_format == 1: # Alpha8: value in the alpha channel + if img.mode == "L": + a = np.asarray(img, np.uint8) + elif img.mode == "RGBA": + a = np.asarray(img, np.uint8)[:, :, 3] + else: + raise NotImplementedError(f"Alpha8 decoded as {img.mode}") + arr = np.zeros(a.shape + (4,), np.uint8) + arr[:, :, 3] = a # rgb 0 (GL alpha texture); TMP reads .a only + else: + arr = np.asarray(img.convert("RGBA"), np.uint8) + if arr.shape[:2] != (height, width): + raise RuntimeError(f"{name}: decoded size {arr.shape}") + return arr[::-1].copy() # bottom row first (Unity v = 0) + + +def pack_group(group: str, handles: list[dict], pixels, width_limit: int) -> tuple[dict, np.ndarray]: + """The layout and the texels of one packed texture `group` of the blocks `handles` (TexelPacker.build). Returns its + record without its file (name, width, height, mipCount, settings, sources, blocks) and its RGBA array, bottom row + first. `pixels(tex)` gives the texels of a block's source texture. Every handle gets its texture (`group`) and its + integer `offset`.""" + settings = {json.dumps(h["settings"], sort_keys=True) for h in handles} + if len(settings) != 1: + raise RuntimeError(f"{group}: blocks from textures with different sampler settings") + # dedupe identical blocks of the same texture + uniq: dict[tuple, dict] = {} + for h in handles: + uniq.setdefault((h["key"], h["box"]), h) + blocks = sorted(uniq.values(), key=lambda h: (-(h["box"][3] - h["box"][1]), h["box"])) + x = y = shelf = 0 + width = max(width_limit, max(b["box"][2] - b["box"][0] for b in blocks)) + for b in blocks: + w, hgt = b["box"][2] - b["box"][0], b["box"][3] - b["box"][1] + if x + w > width: + x, y, shelf = 0, y + shelf, 0 + b["dst"] = (x, y) + x += w + shelf = max(shelf, hgt) + height = y + shelf + if len(blocks) == 1: # a single block keeps its own size + width = blocks[0]["box"][2] - blocks[0]["box"][0] + out = np.zeros((height, width, 4), np.uint8) + for b in blocks: + x0, y0, x1, y1 = b["box"] + dx, dy = b["dst"] + if "array" in b: + out[dy : dy + (y1 - y0), dx : dx + (x1 - x0)] = b["array"] + continue + src = pixels(b["tex"]) + st = b["settings"] + rows = np.take(np.arange(src.shape[0]), np.arange(y0, y1), mode=wrap_mode(st["m_WrapV"])) + cols = np.take(np.arange(src.shape[1]), np.arange(x0, x1), mode=wrap_mode(st["m_WrapU"])) + out[dy : dy + (y1 - y0), dx : dx + (x1 - x0)] = src[np.ix_(rows, cols)] + for h in handles: + d = uniq[(h["key"], h["box"])]["dst"] + h["offset"] = (d[0] - h["box"][0], d[1] - h["box"][1]) + h["texture"] = group + s = next(iter(handles))["settings"] + rec = { + "name": group, + "width": width, + "height": height, + "mipCount": 1, + "settings": s, + "sources": sorted({h["srcName"] for h in handles}), + "blocks": len(blocks), + } + return rec, out + + +def texture_block( + key: tuple, settings: dict, name: str, size: tuple[int, int], box: tuple[int, int, int, int], tex +) -> dict: + """A block that copies the texel box `box` of the texture `tex` (its texels come from the packer's `pixels`). + `key` identifies the texture, `settings` is its m_TextureSettings, `name` and `size` (width, height) its name and + size.""" + return {"tex": tex, "key": key, "settings": settings, "box": box, "srcName": name, "srcSize": size} + + +def array_block(name: str, rgba: np.ndarray, origin: tuple[int, int], settings: dict) -> dict: + """A block of generated texels: `rgba` (RGBA, bottom row first) placed at `origin` of its virtual texture.""" + ox, oy = origin + return { + "array": rgba, + "key": ("generated", name), + "settings": settings, + "box": (ox, oy, ox + rgba.shape[1], oy + rgba.shape[0]), + "srcName": name, + "srcSize": (rgba.shape[1], rgba.shape[0]), + } + + class TexelPacker: """Copies texel blocks of source textures into packed textures. @@ -155,119 +259,76 @@ def pixels(self, tex_obj) -> np.ndarray: ck = PIXELS.key(_decode_inputs(tex), tt["m_TextureFormat"] == 1, tt["m_Width"], tt["m_Height"]) arr = PIXELS.get(ck) if arr is None: - img = texture_image(tex) - if tt["m_TextureFormat"] == 1: # Alpha8: value in the alpha channel - if img.mode == "L": - a = np.asarray(img, np.uint8) - elif img.mode == "RGBA": - a = np.asarray(img, np.uint8)[:, :, 3] - else: - raise NotImplementedError(f"Alpha8 decoded as {img.mode}") - arr = np.zeros(a.shape + (4,), np.uint8) - arr[:, :, 3] = a # rgb 0 (GL alpha texture); TMP reads .a only - else: - arr = np.asarray(img.convert("RGBA"), np.uint8) - if arr.shape[:2] != (tt["m_Height"], tt["m_Width"]): - raise RuntimeError(f"{tt['m_Name']}: decoded size {arr.shape}") - arr = arr[::-1].copy() # bottom row first (Unity v = 0) + arr = texel_rows(texture_image(tex), tt["m_TextureFormat"], tt["m_Width"], tt["m_Height"], tt["m_Name"]) arr.setflags(write=False) PIXELS.put(ck, arr, arr.nbytes) self._pixels[k] = arr return self._pixels[k] - @staticmethod - def _wrap(mode: int) -> Literal["wrap", "clip"]: - if mode == 0: - return "wrap" - if mode == 1: - return "clip" - raise NotImplementedError(f"texture wrap mode {mode}") - def add(self, group: str, tex_obj, x0: int, y0: int, x1: int, y1: int) -> dict: """Register a block; returns a handle filled in by build().""" tt = tex_obj.read_typetree() - h = { - "tex": tex_obj, - "key": _key(tex_obj), - "settings": tt["m_TextureSettings"], - "box": (x0, y0, x1, y1), - "srcName": tt["m_Name"], - "srcSize": (tt["m_Width"], tt["m_Height"]), - } + size = (tt["m_Width"], tt["m_Height"]) + h = texture_block(_key(tex_obj), tt["m_TextureSettings"], tt["m_Name"], size, (x0, y0, x1, y1), tex_obj) self.groups.setdefault(group, []).append(h) return h def add_array(self, group: str, name: str, rgba: np.ndarray, origin: tuple[int, int], settings: dict) -> dict: """Register generated texels (RGBA, bottom row first) covering [origin, origin + shape) of a virtual texture as one block; the whole array is copied (no wrap).""" - ox, oy = origin - h = { - "array": rgba, - "key": ("generated", name), - "settings": settings, - "box": (ox, oy, ox + rgba.shape[1], oy + rgba.shape[0]), - "srcName": name, - "srcSize": (rgba.shape[1], rgba.shape[0]), - } + h = array_block(name, rgba, origin, settings) self.groups.setdefault(group, []).append(h) return h def build(self) -> dict: textures = {} for group, handles in self.groups.items(): - settings = {json.dumps(h["settings"], sort_keys=True) for h in handles} - if len(settings) != 1: - raise RuntimeError(f"{group}: blocks from textures with different sampler settings") - # dedupe identical blocks of the same texture - uniq: dict[tuple, dict] = {} - for h in handles: - uniq.setdefault((h["key"], h["box"]), h) - blocks = sorted(uniq.values(), key=lambda h: (-(h["box"][3] - h["box"][1]), h["box"])) - x = y = shelf = 0 - width = max(self.WIDTH, max(b["box"][2] - b["box"][0] for b in blocks)) - for b in blocks: - w, hgt = b["box"][2] - b["box"][0], b["box"][3] - b["box"][1] - if x + w > width: - x, y, shelf = 0, y + shelf, 0 - b["dst"] = (x, y) - x += w - shelf = max(shelf, hgt) - height = y + shelf - if len(blocks) == 1: # a single block keeps its own size - width = blocks[0]["box"][2] - blocks[0]["box"][0] - out = np.zeros((height, width, 4), np.uint8) - for b in blocks: - x0, y0, x1, y1 = b["box"] - dx, dy = b["dst"] - if "array" in b: - out[dy : dy + (y1 - y0), dx : dx + (x1 - x0)] = b["array"] - continue - src = self.pixels(b["tex"]) - st = b["settings"] - rows = np.take(np.arange(src.shape[0]), np.arange(y0, y1), mode=self._wrap(st["m_WrapV"])) - cols = np.take(np.arange(src.shape[1]), np.arange(x0, x1), mode=self._wrap(st["m_WrapU"])) - out[dy : dy + (y1 - y0), dx : dx + (x1 - x0)] = src[np.ix_(rows, cols)] - for h in handles: - d = uniq[(h["key"], h["box"])]["dst"] - h["offset"] = (d[0] - h["box"][0], d[1] - h["box"][1]) - h["texture"] = group + rec, out = pack_group(group, handles, self.pixels, self.WIDTH) fn = f"textures/{_safe(group)}.png" (self.out / "textures").mkdir(parents=True, exist_ok=True) (self.out / fn).write_bytes(packed_png(out)) - s = next(iter(handles))["settings"] - textures[group] = { - "texture": fn, - "name": group, - "width": width, - "height": height, - "mipCount": 1, - "settings": s, - "sources": sorted({h["srcName"] for h in handles}), - "blocks": len(blocks), - } + textures[group] = {"texture": fn, **rec} return textures +class MemoryPacker: + """TexelPacker over texture objects held in memory instead of Unity objects: a block's texture has `key`, `name`, + `settings` (m_TextureSettings), `width`, `height` and `pixels` (its RGBA texels, bottom row first). `build()` gives + the packed textures' records and their PNG bytes by file name, the same records and bytes TexelPacker writes.""" + + WIDTH = TexelPacker.WIDTH + + def __init__(self): + self.groups: dict[str, list] = {} + + @staticmethod + def pixels(tex) -> np.ndarray: + return tex.pixels + + def add(self, group: str, tex, x0: int, y0: int, x1: int, y1: int) -> dict: + """Register a block; returns a handle filled in by build().""" + h = texture_block(tex.key, tex.settings, tex.name, (tex.width, tex.height), (x0, y0, x1, y1), tex) + self.groups.setdefault(group, []).append(h) + return h + + def add_array(self, group: str, name: str, rgba: np.ndarray, origin: tuple[int, int], settings: dict) -> dict: + """Register generated texels as TexelPacker.add_array does.""" + h = array_block(name, rgba, origin, settings) + self.groups.setdefault(group, []).append(h) + return h + + def build(self) -> tuple[dict, dict[str, bytes]]: + """The records of the packed textures by group, and the PNG bytes of their pages by file name.""" + textures: dict[str, dict] = {} + pages: dict[str, bytes] = {} + for group, handles in self.groups.items(): + rec, out = pack_group(group, handles, self.pixels, self.WIDTH) + fn = f"textures/{_safe(group)}.png" + pages[fn] = packed_png(out) + textures[group] = {"texture": fn, **rec} + return textures, pages + + CLIP_FORMATS = ("generic", "livescene", "livenotes") diff --git a/src/nnnotes/unity/tmpfont.py b/src/nnnotes/unity/tmpfont.py index 37ff93b..241cddc 100644 --- a/src/nnnotes/unity/tmpfont.py +++ b/src/nnnotes/unity/tmpfont.py @@ -19,13 +19,12 @@ import io import math from collections.abc import Callable -from typing import cast +from typing import Protocol, cast import numpy as np -from UnityPy.files import ObjectReader from ..source.config import ConfigError -from .export import Exporter, TexelPacker, _key, grow_box +from .export import Exporter, MemoryPacker, TexelPacker, _key, grow_box from .fontsettings import ( END_OF_TEXT, FONT_CLASS, @@ -60,9 +59,49 @@ def require_extra(what: str = "TextMesh Pro font assets") -> None: # -------------------------------------------------------------------------- # fonts # -------------------------------------------------------------------------- +def font_cmap(data: bytes, face_index: int) -> tuple[set[int], dict[tuple[int, int], str]]: + """The code points a font file maps (its best cmap) and its variation sequences that map a glyph other than the + base's (cmap format 14): {(base, selector): the variant's glyph name}.""" + from fontTools.ttLib import TTFont + + ft = TTFont(io.BytesIO(data), fontNumber=face_index, lazy=True) + best = ft.getBestCmap() or {} + variants: dict[tuple[int, int], str] = {} + for t in ft["cmap"].tables: # variation sequences (cmap format 14), non-default ones + if t.format == 14: + for vs, entries in t.uvsDict.items(): + for base, glyph in entries: + if glyph is not None and glyph != best.get(base): + variants[(base, vs)] = glyph + return set(best), variants + + +class FontAsset(Protocol): + """A TextMesh Pro font asset as the lookup, the glyph tables and the runtime glyphs read it: Font (read through an + Exporter) and tmpassets.Font (read from an object table).""" + + name: str + tt: dict + chars: dict + glyphs: dict + dynamic: bool + cmap: set[int] + variants: dict[tuple[int, int], str] + font_data: bytes | None + source_font: dict | None + atlases: list + + def atlas_names(self) -> list[str | None]: ... + + def atlas_settings(self) -> list[dict]: ... + + def default_material(self) -> dict: ... + + class Font: def __init__(self, ex: Exporter, obj): self.obj = obj + self._ex = ex tt = obj.read_typetree() self.tt = tt self.name = tt["m_Name"] @@ -79,18 +118,8 @@ def __init__(self, ex: Exporter, obj): self.font_data: bytes | None = None src = ex.deref(obj, tt["m_SourceFontFile"]) if tt["m_SourceFontFile"]["m_PathID"] else None if src is not None: - from fontTools.ttLib import TTFont - data = bytes(src.read_typetree()["m_FontData"]) - ft = TTFont(io.BytesIO(data), fontNumber=tt["m_FaceInfo"]["m_FaceIndex"], lazy=True) - best = ft.getBestCmap() or {} - self.cmap = set(best) - for t in ft["cmap"].tables: # variation sequences (cmap format 14), non-default ones - if t.format == 14: - for vs, entries in t.uvsDict.items(): - for base, glyph in entries: - if glyph is not None and glyph != best.get(base): - self.variants[(base, vs)] = glyph + self.cmap, self.variants = font_cmap(data, tt["m_FaceInfo"]["m_FaceIndex"]) self.font_data = data self.source_font = { "name": src.read_typetree()["m_Name"], @@ -100,35 +129,33 @@ def __init__(self, ex: Exporter, obj): else: self.source_font = None + def atlas_names(self) -> list[str | None]: + """The names of the atlas textures (None for a missing page).""" + return [a.read_typetree()["m_Name"] if a is not None else None for a in self.atlases] -class FontSet: - """Font assets reachable from the localized fonts, with TMP character lookup.""" + def atlas_settings(self) -> list[dict]: + """The sampler settings of the atlas textures that are there.""" + return [a.read_typetree()["m_TextureSettings"] for a in self.atlases if a is not None] - def __init__(self, ex: Exporter): - self.ex = ex - self.fonts: dict[str, Font] = {} - self._by_obj: dict[tuple, Font] = {} + def default_material(self) -> dict: + """The record of the default material (Exporter.material).""" + return self._ex.material(self.material_obj) - def by_key(self, key: str) -> Font: - return self.of(self.ex.key_object(key)) - def of(self, obj) -> Font: - k = _key(obj) - if k not in self._by_obj: - f = Font(self.ex, obj) - if f.name in self.fonts: - raise RuntimeError(f"two font assets named {f.name}") - self._by_obj[k] = f - self.fonts[f.name] = f - for fb in f.fallback_objs: - if fb is not None: - self.of(fb) - return self._by_obj[k] +class FontLookup: + """TMP character lookup over font assets: a font's own characters, then its fallback chain (`fallbacks(f)`, as + TextMeshPro searches it; see lookup). Results are (kind, font asset) with kind "baked", "runtime" (a dynamic asset + renders it at runtime from its source font) or "synthesized" (TextMeshPro's control characters). A subclass adds + the font assets it reads to `fonts` and the names of their fallbacks to `_fallback_names`.""" + + def __init__(self): + self.fonts: dict[str, FontAsset] = {} + self._fallback_names: dict[str, list[str]] = {} - def fallbacks(self, f: Font) -> list[Font]: - return [self._by_obj[_key(o)] for o in f.fallback_objs if o is not None] + def fallbacks(self, f: FontAsset) -> list[FontAsset]: + return [self.fonts[n] for n in self._fallback_names[f.name]] - def _in_font(self, f: Font, u: int): + def _in_font(self, f: FontAsset, u: int) -> tuple[str, FontAsset] | None: """GetCharacterFromFontAsset_Internal without fallbacks (regular weight, no italic).""" if u in f.chars: return ("baked", f) @@ -138,7 +165,7 @@ def _in_font(self, f: Font, u: int): return ("runtime", f) # TryAddCharacterInternal: SDF rendered at runtime return None - def _search(self, f: Font, u: int, searched: set): + def _search(self, f: FontAsset, u: int, searched: set) -> tuple[str, FontAsset] | None: r = self._in_font(f, u) if r: return r @@ -151,7 +178,7 @@ def _search(self, f: Font, u: int, searched: set): return r return None - def lookup(self, primary: Font, u: int): + def lookup(self, primary: FontAsset, u: int) -> tuple[str, FontAsset] | None: """TMP_Text.GetTextElement for a font asset with no global fallbacks set.""" r = self._in_font(primary, u) if r: @@ -170,6 +197,33 @@ def baked_anywhere(self, u: int) -> list[str]: return sorted(f.name for f in self.fonts.values() if u in f.chars) +class FontSet(FontLookup): + """Font assets reachable from the localized fonts, with TMP character lookup.""" + + def __init__(self, ex: Exporter): + super().__init__() + self.ex = ex + self._by_obj: dict[tuple, Font] = {} + + def by_key(self, key: str) -> Font: + return self.of(self.ex.key_object(key)) + + def loaded(self, name: str) -> Font: + """The font asset `name`, read by `of` or `by_key`.""" + return cast(Font, self.fonts[name]) + + def of(self, obj) -> Font: + k = _key(obj) + if k not in self._by_obj: + f = Font(self.ex, obj) + if f.name in self.fonts: + raise RuntimeError(f"two font assets named {f.name}") + self._by_obj[k] = f + self.fonts[f.name] = f + self._fallback_names[f.name] = [self.of(fb).name for fb in f.fallback_objs if fb is not None] + return self._by_obj[k] + + # -------------------------------------------------------------------------- # missing glyph # -------------------------------------------------------------------------- @@ -186,7 +240,7 @@ def missing_glyph_character(player) -> int: return tt["m_missingGlyphCharacter"] or MISSING_GLYPH_CHARACTER -def missing_glyph(fonts: FontSet, font: Font, chars, character: int = MISSING_GLYPH_CHARACTER) -> dict | None: +def missing_glyph(fonts: FontLookup, font: FontAsset, chars, character: int = MISSING_GLYPH_CHARACTER) -> dict | None: """The missing glyph of a text of `font` (TMP_Text.SetArraySizes, a character GetTextElement finds in no font asset and no sprite asset): {unicode: the substitute, characters: the code points of `chars` that `font` and its fallbacks lack}, or None when they have every one. The substitute is `character` (missing_glyph_character) when @@ -199,14 +253,14 @@ def missing_glyph(fonts: FontSet, font: Font, chars, character: int = MISSING_GL return {"unicode": sub, "characters": miss} -def underline_character(fonts: FontSet, font: Font) -> bool: +def underline_character(fonts: FontLookup, font: FontAsset) -> bool: """Whether `font` itself has U+005F (TMP_Text.GetUnderlineSpecialCharacter: GetCharacterFromFontAsset without fallbacks; a dynamic asset adds it from its source font): the glyph its texts' underline and highlight draw with.""" r = fonts._in_font(font, 0x5F) return r is not None and r[0] in ("baked", "runtime") -def check_glyph_variants(fonts: FontSet, font: Font, texts) -> None: +def check_glyph_variants(fonts: FontLookup, font: FontAsset, texts) -> None: """Raise NotImplementedError when a character of `texts` that `font` (or a fallback) has is followed by a variation selector its source font maps to a variant glyph (SetArraySizes: GetGlyphVariantIndex -> the alternative glyph); any other selector after a font character is dropped (rewritten to U+001A).""" @@ -270,7 +324,7 @@ class RuntimeGlyphs: "the SDF block is the rect grown by padding on each side" ) - def __init__(self, font: Font): + def __init__(self, font: FontAsset): import freetype from scipy.ndimage import distance_transform_edt @@ -406,7 +460,7 @@ def sdf(self, gi: int) -> tuple[dict, tuple, np.ndarray]: a = np.floor(255.0 * np.clip(0.5 + d / (2.0 * self.spread), 0.0, 1.0) + 0.5).astype(np.uint8) return m, (xmin, ymin, w, h), a - def validate(self, packer: TexelPacker, units) -> dict: + def validate(self, packer: TexelPacker | MemoryPacker, units) -> dict: """Generate the baked characters `units` of the same font asset and compare with its atlas.""" f, p = self.font, self.pad errs, per, metric_diff = [], {}, {k: 0.0 for k in GLYPH_METRIC_FIELDS} @@ -462,15 +516,29 @@ def rgba_alpha(a: np.ndarray) -> np.ndarray: return out -def material_by_name(ex: Exporter, font: Font, mat_name: str): +def material_by_name(ex: Exporter, font: FontAsset, mat_name: str): key = font_dir(font.name) + mat_name if not ex.cat.has(key): raise KeyError(f"material key {key}") return ex.material(ex.key_object(key)) +def shared_settings(name: str, settings: list[dict]) -> dict: + """The sampler settings that all the atlas pages of the font asset `name` have (RuntimeError when they differ or + there is no page).""" + if not settings or any(s != settings[0] for s in settings): + raise RuntimeError(f"{name}: atlas pages with different sampler settings") + return settings[0] + + def runtime_glyphs( - f: Font, extra: set, baked: set, margin: int, packer: TexelPacker, chars_out: dict, glyphs_out: dict + f: FontAsset, + extra: set, + baked: set, + margin: int, + packer: TexelPacker | MemoryPacker, + chars_out: dict, + glyphs_out: dict, ) -> dict: """Characters of a dynamic font asset that TMP renders into the atlas at runtime: generate their SDF glyphs + metrics from the source font, add them to the glyph/character tables and the packed @@ -480,9 +548,7 @@ def runtime_glyphs( if margin < p: raise RuntimeError(f"{f.name}: sampling margin {margin} < padding {p}") validation = gen.validate(packer, baked) - settings = [a.read_typetree()["m_TextureSettings"] for a in f.atlases if a is not None] - if not settings or any(s != settings[0] for s in settings): - raise RuntimeError(f"{f.name}: atlas pages with different sampler settings") + settings = shared_settings(f.name, f.atlas_settings()) x = 0 generated = [] for u in sorted(extra): @@ -504,7 +570,7 @@ def runtime_glyphs( f"runtime glyph {gi}", rgba_alpha(cell), (rect["m_X"] - margin, rect["m_Y"] - margin), - settings[0], + settings, ) chars_out[str(u)] = {"glyph": gi, "scale": 1.0, "elementType": 1} glyphs_out[str(gi)] = rec @@ -547,7 +613,7 @@ def runtime_glyphs( # -------------------------------------------------------------------------- # characters -> font assets, materials, glyph tables # -------------------------------------------------------------------------- -def glyph_coverage(fonts: FontSet, primary: Font, chars: list[int]) -> tuple[dict, dict, dict]: +def glyph_coverage(fonts: FontLookup, primary: FontAsset, chars: list[int]) -> tuple[dict, dict, dict]: """TMP character lookup of `chars` (distinct code points) from `primary` through its fallback chain. -> (coverage report, {font: baked units}, {font: runtime units}).""" coverage = { @@ -595,31 +661,44 @@ def glyph_coverage(fonts: FontSet, primary: Font, chars: list[int]) -> tuple[dic return coverage, needed, runtime_units -def text_materials( - ex: Exporter, fonts: FontSet, localized: list[dict], primaries: set, needed: dict, runtime_units: dict +def text_material_records( + fonts: FontLookup, + localized: list[dict], + primaries: set, + needed: dict, + runtime_units: dict, + material_named: Callable[[FontAsset, str], dict], ) -> tuple[dict, dict]: - """The localized text materials (in text order), then the default material of every font asset that - serves a character or is a primary. -> (all materials, text materials).""" + """The localized text materials (in text order, `material_named(font asset, name)`), then the default material of + every font asset that serves a character or is a primary. -> (all materials, text materials).""" materials: dict[str, dict] = {} for loc in localized: if loc["material"] not in materials: - materials[loc["material"]] = material_by_name(ex, fonts.fonts[loc["fontAsset"]], loc["material"]) + materials[loc["material"]] = material_named(fonts.fonts[loc["fontAsset"]], loc["material"]) text_mats = dict(materials) for f in fonts.fonts.values(): if f.name in needed or f.name in runtime_units or f.name in primaries: - m = ex.material(f.material_obj) + m = f.default_material() materials.setdefault(m["material"], m) return materials, text_mats +def text_materials( + ex: Exporter, fonts: FontSet, localized: list[dict], primaries: set, needed: dict, runtime_units: dict +) -> tuple[dict, dict]: + """text_material_records for the font assets of `fonts`, their materials read through `ex`.""" + return text_material_records( + fonts, localized, primaries, needed, runtime_units, lambda f, name: material_by_name(ex, f, name) + ) + + def export_fonts( - ex: Exporter, - fonts: FontSet, + fonts: FontLookup, primaries: set, needed: dict, runtime_units: dict, text_mats: dict, - packer: TexelPacker, + packer: TexelPacker | MemoryPacker, subset: str, ) -> dict: """Font asset records with the character / glyph tables reduced to the `needed` baked characters (plus the @@ -631,7 +710,7 @@ def export_fonts( extra = runtime_units.get(f.name, set()) if f.name not in primaries and not units and not extra: continue - mat = ex.material(f.material_obj) + mat = f.default_material() gs = mat["floats"]["_GradientScale"] aw, ah = f.tt["m_AtlasWidth"], f.tt["m_AtlasHeight"] # the runtime replaces _TextureWidth/_TextureHeight with the packed size @@ -679,7 +758,7 @@ def export_fonts( "atlasPadding": tt["m_AtlasPadding"], "atlasRenderMode": tt["m_AtlasRenderMode"], "atlasPopulationMode": tt["m_AtlasPopulationMode"], - "atlases": [a.read_typetree()["m_Name"] if a is not None else None for a in f.atlases], + "atlases": f.atlas_names(), "normalStyle": tt["normalStyle"], "normalSpacingOffset": tt["normalSpacingOffset"], "boldStyle": tt["boldStyle"], @@ -739,46 +818,62 @@ def resolve_font_blocks(font_out: dict) -> None: # ---------------------------------------------------------------- the font settings of the game's assets -class AssetFontSettings(FontSettings): +def font_record(f: FontAsset, material: str, fallbacks: list[str], bundle: str) -> dict: + """The table's record of the font asset `f` (FontSettings.font): face info, atlas padding and render mode, style + settings, default material name, fallback names, coverage, variants and the bundle that holds it.""" + tt = f.tt + return { + "faceInfo": tt["m_FaceInfo"], + "atlasPadding": tt["m_AtlasPadding"], + "atlasRenderMode": tt["m_AtlasRenderMode"], + **{k: tt[k] for k in STYLE_KEYS}, + "material": material, + "fallbacks": fallbacks, + "coverage": encode_coverage(set(f.chars) | (f.cmap if f.dynamic else set())), + "variants": sorted([b, vs] for b, vs in f.variants), + "bundle": bundle, + } + + +class BuiltFontSettings(FontSettings): + """FontSettings read from font assets, which record the path id of each object they read (font_document).""" + + def __init__(self): + super().__init__({"format": FORMAT, "fonts": {}, "materials": {}}) + self.font_ids: dict[str, int] = {} # font asset name -> its object's path id + self.material_ids: dict[str, int] = {} # material name -> its object's path id + + +class AssetFontSettings(BuiltFontSettings): """FontSettings read from the game's font assets (`ex`: an Exporter of the catalog and player data): a font asset at its address (fontsettings.font_key), its fallbacks through their references (FontSet), its materials at their addresses. Needs the `fonts` extra for the source font files of dynamic assets.""" def __init__(self, ex): - super().__init__({"format": FORMAT, "fonts": {}, "materials": {}}) + super().__init__() self.ex = ex self._set = None - self._mat_objs: dict[str, ObjectReader] = {} # material name -> its object def _fontset(self): if self._set is None: self._set = FontSet(self.ex) return self._set - def _font_obj(self, name: str): + def _font_obj(self, name: str) -> Font: fs = self._fontset() if name not in fs.fonts: fs.by_key(font_key(self.ex.cat, name, name)) - return fs.fonts[name] + return fs.loaded(name) def font(self, name: str) -> dict: if name not in self.fonts: f = self._font_obj(name) - mat = self.ex.material(f.material_obj) + mat = f.default_material() self.materials.setdefault(mat["material"], mat) - self._mat_objs.setdefault(mat["material"], f.material_obj) - tt = f.tt - self.fonts[name] = { - "faceInfo": tt["m_FaceInfo"], - "atlasPadding": tt["m_AtlasPadding"], - "atlasRenderMode": tt["m_AtlasRenderMode"], - **{k: tt[k] for k in STYLE_KEYS}, - "material": mat["material"], - "fallbacks": [fb.name for fb in self._fontset().fallbacks(f)], - "coverage": encode_coverage(set(f.chars) | (f.cmap if f.dynamic else set())), - "variants": sorted([b, vs] for b, vs in f.variants), - "bundle": f.obj.assets_file.parent.name, - } + self.material_ids.setdefault(mat["material"], f.material_obj.path_id) + self.font_ids[name] = f.obj.path_id + fallbacks = [fb.name for fb in self._fontset().fallbacks(f)] + self.fonts[name] = font_record(f, mat["material"], fallbacks, f.obj.assets_file.parent.name) return self.fonts[name] def has_material(self, font: str, name: str) -> bool: @@ -791,22 +886,25 @@ def material(self, font: str | None, name: str) -> dict: key = font_key(self.ex.cat, font, name) if not self.ex.cat.has(key): raise KeyError(f"material key {key}") - self._mat_objs[name] = self.ex.key_object(key) - self.materials[name] = self.ex.material(self._mat_objs[name]) + obj = self.ex.key_object(key) + self.material_ids[name] = obj.path_id + self.materials[name] = self.ex.material(obj) return copy.deepcopy(self.materials[name]) def where(self) -> str: return "the game's font assets" -def build_font_settings(ex, player, game_version: str) -> dict: - """The font settings table (fontsettings.FORMAT) from the game's assets: every font asset LocalizeManager lists - for a language (font names and additional font names) with the assets of their fallback chains, their default - materials and the localized text materials of the listed assets that the catalog has (table_material), and the - objects index of those assets.""" - lang = language_fonts(player) - settings = AssetFontSettings(ex) - listed = sorted({f"{n} SDF" for d in lang["languages"].values() for n in d["fontNames"] + d["additionalFontNames"]}) +def listed_fonts(lang: dict) -> list[str]: + """The font assets LocalizeManager lists for the languages (`lang`: textstyle.language_fonts): " SDF" for + every font name and additional font name, sorted.""" + return sorted({f"{n} SDF" for d in lang["languages"].values() for n in d["fontNames"] + d["additionalFontNames"]}) + + +def load_listed(settings: BuiltFontSettings, lang: dict) -> list[str]: + """Reads the font assets `lang` lists with their fallback chains, and the text materials of the listed assets + (one per material type of `lang`) that the game has. -> the names of the font assets read, sorted.""" + listed = listed_fonts(lang) todo, seen = list(listed), set() while todo: name = todo.pop(0) @@ -818,15 +916,27 @@ def build_font_settings(ex, player, game_version: str) -> dict: for t in lang["materialTypes"]: if settings.has_material(name, f"{base} - {t}"): settings.material(name, f"{base} - {t}") - fs = settings._fontset() - objects = { - str(fs.fonts[n].obj.path_id): {"type": "MonoBehaviour", "class": FONT_CLASS, "name": n} for n in sorted(seen) - } - objects.update({str(o.path_id): {"type": "Material", "name": n} for n, o in sorted(settings._mat_objs.items())}) + return sorted(seen) + + +def font_document(settings: BuiltFontSettings, names: list[str], game_version: str) -> dict: + """The table (fontsettings.FORMAT) of the font assets `names` and of the materials `settings` read (table_material), + with the path id, type and name of each of those objects as the table's `objects` index.""" + objects = {str(settings.font_ids[n]): {"type": "MonoBehaviour", "class": FONT_CLASS, "name": n} for n in names} + objects.update({str(pid): {"type": "Material", "name": n} for n, pid in sorted(settings.material_ids.items())}) return { "format": FORMAT, "gameVersion": game_version, - "fonts": {n: settings.fonts[n] for n in sorted(seen)}, + "fonts": {n: settings.fonts[n] for n in names}, "materials": {n: table_material(settings.materials[n]) for n in sorted(settings.materials)}, "objects": dict(sorted(objects.items(), key=lambda kv: int(kv[0]))), } + + +def build_font_settings(ex, player, game_version: str) -> dict: + """The font settings table (fontsettings.FORMAT) from the game's assets: every font asset LocalizeManager lists + for a language (font names and additional font names) with the assets of their fallback chains, their default + materials and the localized text materials of the listed assets that the catalog has (table_material), and the + objects index of those assets.""" + settings = AssetFontSettings(ex) + return font_document(settings, load_listed(settings, language_fonts(player)), game_version) diff --git a/tests/content/test_story_fonts.py b/tests/content/test_story_fonts.py index f4608c4..010b5ae 100644 --- a/tests/content/test_story_fonts.py +++ b/tests/content/test_story_fonts.py @@ -15,82 +15,7 @@ from nnnotes.content import advui, storyfonts from nnnotes.core import cache from nnnotes.unity import fontsettings, tmpfont # noqa: E402 - -UPEM = 1000 - - -def make_font(path, family="Test Sans", style="Regular", underscore=False, drop=(), extra=None): - """A TrueType font: .notdef, space, A (square), B (triangle), U+53E3 (square with a square hole), H (square) and - x (a box 510 units high); with `underscore` also U+005F (a bar below the baseline); the code points `drop` left - out of the character map, `extra` {code point: glyph name} added to it.""" - from fontTools.fontBuilder import FontBuilder - from fontTools.pens.ttGlyphPen import TTGlyphPen - - def glyph(contours): - pen = TTGlyphPen(None) - for pts in contours: - pen.moveTo(pts[0]) - for p in pts[1:]: - pen.lineTo(p) - pen.closePath() - return pen.glyph() - - square = [(100, 0), (100, 700), (600, 700), (600, 0)] - fb = FontBuilder(UPEM, isTTF=True) - order = [".notdef", "space", "A", "B", "kou", "H", "x"] + (["underscore"] if underscore else []) - fb.setupGlyphOrder(order) - cmap = {0x20: "space", 0x41: "A", 0x42: "B", 0x53E3: "kou", 0x48: "H", 0x78: "x"} - glyphs = { - ".notdef": glyph([square]), - "space": glyph([]), - "A": glyph([square]), - "B": glyph([[(50, 0), (350, 650), (650, 0)]]), - "kou": glyph( - [[(80, -50), (80, 750), (920, 750), (920, -50)], [(250, 150), (750, 150), (750, 550), (250, 550)]] - ), - "H": glyph([square]), - "x": glyph([[(50, 0), (50, 510), (450, 510), (450, 0)]]), - } - metrics = { - ".notdef": (700, 100), - "space": (333, 0), - "A": (700, 100), - "B": (700, 50), - "kou": (1000, 80), - "H": (700, 100), - "x": (500, 50), - } - if underscore: - cmap[0x5F] = "underscore" - glyphs["underscore"] = glyph([[(0, -150), (0, -100), (500, -100), (500, -150)]]) - metrics["underscore"] = (500, 0) - cmap = {u: g for u, g in {**cmap, **(extra or {})}.items() if u not in drop} - fb.setupCharacterMap(cmap) - fb.setupGlyf(glyphs) - fb.setupHorizontalMetrics(metrics) - fb.setupHorizontalHeader(ascent=880, descent=-120, lineGap=0) - fb.setupOS2( - sTypoAscender=880, - sTypoDescender=-120, - usWinAscent=880, - usWinDescent=120, - sCapHeight=700, - sxHeight=500, - version=4, - ) - fb.setupPost(underlinePosition=-100, underlineThickness=50) - fb.setupNameTable( - { - "familyName": family, - "styleName": style, - "version": "Version 1.000", - "licenseInfoURL": "https://example.invalid/license", - } - ) - buf = io.BytesIO() - fb.save(buf) - path.write_bytes(buf.getvalue()) - return path +from tests.helpers.fonts import UPEM, make_font @pytest.fixture diff --git a/tests/content/test_tmpassets.py b/tests/content/test_tmpassets.py new file mode 100644 index 0000000..52227f9 --- /dev/null +++ b/tests/content/test_tmpassets.py @@ -0,0 +1,409 @@ +"""TextMesh Pro font assets read from an object table (content/tmpassets.py), their lookup and settings, and the +in-memory texture packer (export.MemoryPacker), on synthetic objects; nothing here comes from game data.""" + +import hashlib +import io +import json + +import numpy as np +import pytest +from PIL import Image + +from nnnotes.content import tmpassets +from nnnotes.unity import fontsettings +from nnnotes.unity.export import MemoryPacker, packed_png +from tests.helpers.fonts import make_font + +FILE = "CAB-tmpassets" +SETTINGS = {"m_FilterMode": 1, "m_Aniso": 1, "m_MipBias": 0.0, "m_WrapU": 1, "m_WrapV": 1, "m_WrapW": 1} +SCRIPT = "Unity.TextMeshPro|TMPro|TMP_FontAsset" + + +def oid(pid: int) -> str: + return f"{FILE}:{pid}" + + +def ref(o: str | None): + """A reference value as the object layer writes it.""" + if o is None: + return None + file, pid = o.rsplit(":", 1) + return {"$ref": {"file": file, "pathId": pid}} + + +class Table: + """Synthetic objects with the protocol of prefabs.Objects. `entries` maps an object id to (class, name, json, + files), files being {role: (bytes, extension)}; a json file is written from the json document when it is not None. + `keys` maps catalog keys to object ids and `roots` lists the root object ids.""" + + def __init__(self, entries, keys=None, roots=()): + self.roots = list(roots) + self._keys = {k: list(v) for k, v in (keys or {}).items()} + self._info: dict[str, dict] = {} + self._data: dict[tuple[str, str], bytes] = {} + for o, (cls, name, doc, files) in entries.items(): + files = dict(files) + if doc is not None: + files["json"] = (json.dumps(doc).encode(), "json") + self._info[o] = { + "class": cls, + "name": name, + "files": { + role: {"sha256": hashlib.sha256(data).hexdigest(), "size": len(data), "ext": ext} + for role, (data, ext) in files.items() + }, + } + for role, (data, _) in files.items(): + self._data[(o, role)] = data + + def __contains__(self, o: str) -> bool: + return o in self._info + + def ids(self, cls=None) -> list[str]: + return sorted(o for o, i in self._info.items() if cls is None or i["class"] == cls) + + def key(self, key: str) -> list[str]: + if key not in self._keys: + raise KeyError(f"{key} is not a key of the product") + return self._keys[key] + + def info(self, o: str) -> dict: + return self._info[o] + + def bytes(self, o: str, role: str) -> bytes: + return self._data[(o, role)] + + def json(self, o: str, role: str = "json"): + return json.loads(self.bytes(o, role)) + + +def png(arr: np.ndarray) -> bytes: + buf = io.BytesIO() + Image.fromarray(arr).save(buf, format="PNG") + return buf.getvalue() + + +def texture_doc(name: str, w: int, h: int, fmt: int) -> dict: + return { + "m_Name": name, + "m_Width": w, + "m_Height": h, + "m_TextureFormat": fmt, + "m_MipCount": 1, + "m_TextureSettings": SETTINGS, + } + + +def tmp_font(name, *, chars=(), fallbacks=(), atlases=(), material=None, source=None, dynamic=False) -> dict: + """The json of a TMP_FontAsset: the fields tmpassets.Font reads, with `chars` in its character table.""" + return { + "$script": SCRIPT, + "m_Name": name, + "m_CharacterTable": [ + {"m_Unicode": u, "m_GlyphIndex": i + 1, "m_Scale": 1.0, "m_ElementType": 1} for i, u in enumerate(chars) + ], + "m_GlyphTable": [], + "m_AtlasPopulationMode": 1 if dynamic else 0, + "m_AtlasTextures": [ref(a) for a in atlases], + "m_Material": ref(material), + "m_FallbackFontAssetTable": [ref(f) for f in fallbacks], + "m_FontWeightTable": [], + "m_SourceFontFile": ref(source), + "m_FaceInfo": {"m_FaceIndex": 0, "m_PointSize": 40.0, "m_UnitsPerEM": 1000}, + "m_AtlasWidth": 256, + "m_AtlasHeight": 256, + "m_AtlasPadding": 4, + "m_AtlasRenderMode": 4134, + "normalStyle": 0.5, + "normalSpacingOffset": 0.0, + "boldStyle": 0.75, + "boldSpacing": 7.0, + "italicStyle": 0.35, + "tabSize": 10, + "m_FontFeatureTable": {"m_GlyphPairAdjustmentRecords": []}, + } + + +def material_doc(name, shader=None, texture=None) -> dict: + """The json of a Material (export.convert material_doc, references as {"$ref"}).""" + return { + "material": name, + "shader": ref(shader), + "keywords": ["OUTLINE_ON"], + "invalidKeywords": [], + "renderQueue": 3000, + "tags": {"RenderType": "Transparent"}, + "disabledPasses": [], + "textures": {"_MainTex": {"texture": ref(texture), "scale": [1.0, 1.0], "offset": [0.0, 0.0]}}, + "ints": {}, + "floats": {"_GradientScale": 4.0}, + "colors": {}, + } + + +def atlas_entries(pid: int, name: str, w: int = 4, h: int = 3) -> dict: + """A Texture2D entry of the table: an RGBA atlas (its PNG holds the texels).""" + return { + oid(pid): ( + "Texture2D", + name, + texture_doc(name, w, h, 4), + {"image": (png(np.zeros((h, w, 4), np.uint8)), "png")}, + ) + } + + +def test_texture_pixels_are_the_decoded_image_bottom_row_first(): + rng = np.random.default_rng(7) + rgba = rng.integers(0, 256, (3, 4, 4), dtype=np.uint8) + entries = {oid(1): ("Texture2D", "Atlas", texture_doc("Atlas", 4, 3, 4), {"image": (png(rgba), "png")})} + tex = tmpassets.Texture(Table(entries), oid(1)) + assert tex.key == (FILE, 1) + assert tex.record() == { + "textureRef": oid(1), + "name": "Atlas", + "width": 4, + "height": 3, + "format": 4, + "mipCount": 1, + "settings": SETTINGS, + } + assert np.array_equal(tex.pixels, rgba[::-1]) + assert not tex.pixels.flags.writeable + + +def test_alpha8_texels_keep_their_value_in_alpha(): + a = np.arange(12, dtype=np.uint8).reshape(3, 4) + entries = {oid(2): ("Texture2D", "Glyphs", texture_doc("Glyphs", 4, 3, 1), {"image": (png(a), "png")})} + px = tmpassets.Texture(Table(entries), oid(2)).pixels + assert np.array_equal(px[:, :, 3], a[::-1]) + assert not px[:, :, :3].any() + + +def test_texture_image_must_be_png(): + entries = {oid(3): ("Texture2D", "Hdr", texture_doc("Hdr", 4, 4, 1), {"image": (b"astc", "astc")})} + with pytest.raises(NotImplementedError, match="image file astc"): + _ = tmpassets.Texture(Table(entries), oid(3)).pixels + + +def test_font_asset_fields_atlases_and_default_material(): + entries = { + oid(10): ( + "MonoBehaviour", + "X SDF", + tmp_font("X SDF", chars=[0x41, 0x42], atlases=[oid(11), None], material=oid(12)), + {}, + ), + **atlas_entries(11, "X Atlas"), + oid(12): ("Material", "X SDF Material", material_doc("X SDF Material", shader=oid(13), texture=oid(11)), {}), + oid(13): ("Shader", "TextMeshPro/Distance Field", {"name": "TextMeshPro/Distance Field"}, {}), + } + fs = tmpassets.FontSet(Table(entries, keys={"EmbFont/X/X SDF": [oid(10)]})) + f = fs.by_key("EmbFont/X/X SDF") + assert f.name == "X SDF" + assert sorted(f.chars) == [0x41, 0x42] + assert not f.dynamic and f.source_font is None and f.font_data is None and f.cmap == set() + assert f.atlas_names() == ["X Atlas", None] + assert f.atlas_settings() == [SETTINGS] + assert f.default_material() == { + "material": "X SDF Material", + "shader": {"shader": "TextMeshPro/Distance Field"}, + "keywords": ["OUTLINE_ON"], + "invalidKeywords": [], + "renderQueue": 3000, + "tags": {"RenderType": "Transparent"}, + "disabledPasses": [], + "textures": { + "_MainTex": { + "texture": { + "textureRef": oid(11), + "name": "X Atlas", + "width": 4, + "height": 3, + "format": 4, + "mipCount": 1, + "settings": SETTINGS, + }, + "scale": [1.0, 1.0], + "offset": [0.0, 0.0], + } + }, + "ints": {}, + "floats": {"_GradientScale": 4.0}, + "colors": {}, + } + + +def test_material_without_shader_or_texture_keeps_them_null(): + entries = {oid(12): ("Material", "M", material_doc("M"), {})} + m = tmpassets.FontSet(Table(entries)).material(oid(12)) + assert m["shader"] is None + assert m["textures"]["_MainTex"]["texture"] is None + + +def test_material_texture_must_be_a_texture2d(): + entries = { + oid(12): ("Material", "M", material_doc("M", texture=oid(11)), {}), + oid(11): ("RenderTexture", "RT", {"m_Name": "RT"}, {}), + } + with pytest.raises(NotImplementedError, match="RenderTexture"): + tmpassets.FontSet(Table(entries)).material(oid(12)) + + +def test_lookup_follows_the_fallback_chain(): + entries = { + oid(1): ("MonoBehaviour", "P SDF", tmp_font("P SDF", chars=[0x41], fallbacks=[oid(2)], material=oid(9)), {}), + oid(2): ("MonoBehaviour", "F SDF", tmp_font("F SDF", chars=[0x42], fallbacks=[oid(3)], material=oid(9)), {}), + oid(3): ("MonoBehaviour", "G SDF", tmp_font("G SDF", chars=[0x43], material=oid(9)), {}), + oid(9): ("Material", "M", material_doc("M"), {}), + } + fs = tmpassets.FontSet(Table(entries, keys={"EmbFont/P/P SDF": [oid(1)]})) + p = fs.by_key("EmbFont/P/P SDF") + assert sorted(fs.fonts) == ["F SDF", "G SDF", "P SDF"] + assert [fb.name for fb in fs.fallbacks(p)] == ["F SDF"] + assert fs.lookup(p, 0x41) == ("baked", p) + assert fs.lookup(p, 0x42)[1].name == "F SDF" + assert fs.lookup(p, 0x43)[1].name == "G SDF" + assert fs.lookup(p, 0x44) is None + assert fs.baked_anywhere(0x43) == ["G SDF"] + + +def test_dynamic_font_reads_its_source_font(tmp_path): + pytest.importorskip("fontTools") + data = make_font(tmp_path / "Test.ttf").read_bytes() + entries = { + oid(1): ( + "MonoBehaviour", + "T SDF", + tmp_font("T SDF", chars=[0x41], dynamic=True, source=oid(4), material=oid(9)), + {}, + ), + oid(4): ("Font", "Test Sans", {"m_Name": "Test Sans"}, {"font": (data, "ttf")}), + oid(9): ("Material", "M", material_doc("M"), {}), + } + f = tmpassets.FontSet(Table(entries)).of(oid(1)) + assert f.dynamic + assert {0x20, 0x41, 0x42, 0x48, 0x78, 0x53E3} <= f.cmap + assert f.variants == {} + assert f.font_data == data + assert f.source_font == {"name": "Test Sans", "bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()} + + +def test_build_font_settings_reads_the_listed_fonts_and_their_materials(): + lang = {"languages": {"ja": {"fontNames": ["X"], "additionalFontNames": []}}, "materialTypes": ["Outline"]} + entries = { + oid(1): ( + "MonoBehaviour", + "X SDF", + tmp_font("X SDF", chars=[0x41], fallbacks=[oid(2)], atlases=[oid(4)], material=oid(3)), + {}, + ), + oid(2): ("MonoBehaviour", "Y SDF", tmp_font("Y SDF", chars=[0x42], material=oid(5)), {}), + oid(3): ("Material", "X SDF Material", material_doc("X SDF Material", texture=oid(4)), {}), + oid(5): ("Material", "Y SDF Material", material_doc("Y SDF Material"), {}), + oid(6): ("Material", "X - Outline", material_doc("X - Outline"), {}), + **atlas_entries(4, "X Atlas"), + } + keys = {"EmbFont/X/X SDF": [oid(1)], "EmbFont/X/X - Outline": [oid(6)]} + doc = tmpassets.build_font_settings( + Table(entries, keys), lang, "9.9.9", lambda name: f"embfont-{name[:-4].lower()}.bundle" + ) + assert doc["format"] == fontsettings.FORMAT and doc["gameVersion"] == "9.9.9" + assert sorted(doc["fonts"]) == ["X SDF", "Y SDF"] + x = doc["fonts"]["X SDF"] + assert x["material"] == "X SDF Material" + assert x["fallbacks"] == ["Y SDF"] + assert x["bundle"] == "embfont-x.bundle" + assert x["atlasPadding"] == 4 and x["faceInfo"] == {"m_FaceIndex": 0, "m_PointSize": 40.0, "m_UnitsPerEM": 1000} + assert fontsettings.decode_coverage(x["coverage"]) == bytes(8) + bytes([0b10]) + assert sorted(doc["materials"]) == ["X - Outline", "X SDF Material", "Y SDF Material"] + assert doc["materials"]["X SDF Material"]["textures"]["_MainTex"]["texture"] == { + "name": "X Atlas", + "width": 4, + "height": 3, + "format": 4, + } + assert doc["objects"] == { + "1": {"type": "MonoBehaviour", "class": "TMP_FontAsset", "name": "X SDF"}, + "2": {"type": "MonoBehaviour", "class": "TMP_FontAsset", "name": "Y SDF"}, + "3": {"type": "Material", "name": "X SDF Material"}, + "5": {"type": "Material", "name": "Y SDF Material"}, + "6": {"type": "Material", "name": "X - Outline"}, + } + table = fontsettings.FontSettings(doc) + assert table.lookup("X SDF", 0x42) == "Y SDF" + + +def test_missing_glyph_character_of_the_table_resource(): + def table(**fields) -> Table: + doc = { + "$script": "Unity.TextMeshPro|TMPro|TMP_Settings", + "m_fallbackFontAssets": [], + "m_defaultFontAsset": None, + "m_missingGlyphCharacter": 0, + } + doc.update(fields) + return Table({oid(9): ("MonoBehaviour", "TMP Settings", doc, {})}, roots=[oid(9)]) + + assert tmpassets.missing_glyph_character(table()) == 0x25A1 + assert tmpassets.missing_glyph_character(table(m_missingGlyphCharacter=0x3F)) == 0x3F + with pytest.raises(NotImplementedError): + tmpassets.missing_glyph_character(table(m_fallbackFontAssets=[ref(oid(4))])) + with pytest.raises(ValueError, match="expected one"): + tmpassets.missing_glyph_character(Table({})) + + +def test_unresolved_reference_raises(): + assert tmpassets._ref(None) is None + with pytest.raises(ValueError, match="does not resolve"): + tmpassets._ref({"$ref": {"fileId": 1, "pathId": "5"}}) + + +class Block: + """A source texture as MemoryPacker reads it: its key, name, sampler settings, size and RGBA texels.""" + + def __init__(self, key, name, pixels): + self.key, self.name, self.settings = key, name, SETTINGS + self.height, self.width = pixels.shape[:2] + self.pixels = pixels + + +def test_memory_packer_lays_out_blocks_and_returns_the_pages(): + a = np.arange(2 * 4 * 4, dtype=np.uint8).reshape(2, 4, 4) + b = (np.arange(2 * 3 * 4, dtype=np.uint8) + 100).reshape(2, 3, 4) + ta, tb = Block((FILE, 1), "A", a), Block((FILE, 2), "B", b) + packer = MemoryPacker() + packer.WIDTH = 4 # the blocks fill one row + ha = packer.add("font_X", ta, 0, 0, 2, 2) + hb = packer.add("font_X", tb, 1, 0, 3, 2) + glyph = np.full((1, 1, 4), 9, np.uint8) + hg = packer.add_array("other", "runtime glyph 7", glyph, (5, 6), SETTINGS) + textures, pages = packer.build() + + out = np.zeros((2, 4, 4), np.uint8) + out[:, 0:2] = a[:, 0:2] + out[:, 2:4] = b[:, 1:3] + assert textures["font_X"] == { + "texture": "textures/font_X.png", + "name": "font_X", + "width": 4, + "height": 2, + "mipCount": 1, + "settings": SETTINGS, + "sources": ["A", "B"], + "blocks": 2, + } + assert textures["other"] == { + "texture": "textures/other.png", + "name": "other", + "width": 1, + "height": 1, + "mipCount": 1, + "settings": SETTINGS, + "sources": ["runtime glyph 7"], + "blocks": 1, + } + assert pages == {"textures/font_X.png": packed_png(out), "textures/other.png": packed_png(glyph)} + assert (ha["texture"], ha["offset"]) == ("font_X", (0, 0)) + assert (hb["texture"], hb["offset"]) == ("font_X", (1, 0)) + assert (hg["texture"], hg["offset"]) == ("other", (-5, -6)) diff --git a/tests/helpers/fonts.py b/tests/helpers/fonts.py new file mode 100644 index 0000000..d01b1cb --- /dev/null +++ b/tests/helpers/fonts.py @@ -0,0 +1,79 @@ +"""Synthetic TrueType fonts for the tests (built with fontTools; nothing here comes from game data).""" + +import io + +UPEM = 1000 + + +def make_font(path, family="Test Sans", style="Regular", underscore=False, drop=(), extra=None): + """A TrueType font: .notdef, space, A (square), B (triangle), U+53E3 (square with a square hole), H (square) and + x (a box 510 units high); with `underscore` also U+005F (a bar below the baseline); the code points `drop` left + out of the character map, `extra` {code point: glyph name} added to it.""" + from fontTools.fontBuilder import FontBuilder + from fontTools.pens.ttGlyphPen import TTGlyphPen + + def glyph(contours): + pen = TTGlyphPen(None) + for pts in contours: + pen.moveTo(pts[0]) + for p in pts[1:]: + pen.lineTo(p) + pen.closePath() + return pen.glyph() + + square = [(100, 0), (100, 700), (600, 700), (600, 0)] + fb = FontBuilder(UPEM, isTTF=True) + order = [".notdef", "space", "A", "B", "kou", "H", "x"] + (["underscore"] if underscore else []) + fb.setupGlyphOrder(order) + cmap = {0x20: "space", 0x41: "A", 0x42: "B", 0x53E3: "kou", 0x48: "H", 0x78: "x"} + glyphs = { + ".notdef": glyph([square]), + "space": glyph([]), + "A": glyph([square]), + "B": glyph([[(50, 0), (350, 650), (650, 0)]]), + "kou": glyph( + [[(80, -50), (80, 750), (920, 750), (920, -50)], [(250, 150), (750, 150), (750, 550), (250, 550)]] + ), + "H": glyph([square]), + "x": glyph([[(50, 0), (50, 510), (450, 510), (450, 0)]]), + } + metrics = { + ".notdef": (700, 100), + "space": (333, 0), + "A": (700, 100), + "B": (700, 50), + "kou": (1000, 80), + "H": (700, 100), + "x": (500, 50), + } + if underscore: + cmap[0x5F] = "underscore" + glyphs["underscore"] = glyph([[(0, -150), (0, -100), (500, -100), (500, -150)]]) + metrics["underscore"] = (500, 0) + cmap = {u: g for u, g in {**cmap, **(extra or {})}.items() if u not in drop} + fb.setupCharacterMap(cmap) + fb.setupGlyf(glyphs) + fb.setupHorizontalMetrics(metrics) + fb.setupHorizontalHeader(ascent=880, descent=-120, lineGap=0) + fb.setupOS2( + sTypoAscender=880, + sTypoDescender=-120, + usWinAscent=880, + usWinDescent=120, + sCapHeight=700, + sxHeight=500, + version=4, + ) + fb.setupPost(underlinePosition=-100, underlineThickness=50) + fb.setupNameTable( + { + "familyName": family, + "styleName": style, + "version": "Version 1.000", + "licenseInfoURL": "https://example.invalid/license", + } + ) + buf = io.BytesIO() + fb.save(buf) + path.write_bytes(buf.getvalue()) + return path