"""The fonts SimVX ships with, and the project keys that choose between them.
Text rendering used to depend on whichever font happened to be installed on the
machine: a bare container or a stripped desktop had none, so text silently did
not draw at all, and two developers exporting the same game baked two different
typefaces. The engine therefore ships fonts of its own and uses them by default.
Three faces, named by the job they do rather than by typeface, so that changing
the typeface is a change in one place:
``"ui"``
Everything a game or the editor draws that is not code: labels, buttons,
menus, dialogs. Noto Sans.
``"mono"``
Fixed-pitch text, where columns must line up: the code editor, the
terminal, and any tabular readout. Noto Sans Mono.
``"fallback"``
Not drawn in directly. It is the last face the fallback chain asks for a
character neither the primary face nor the machine's own fonts can draw,
and it is DejaVu Sans Mono because that face is the widest-covering one
the engine ships: Arabic, Armenian, Georgian, Lao, the box-drawing and
block-element blocks, the mathematical and APL operators. Without it a
bare container would draw missing-glyph boxes for every one of them.
Ask for one by name::
from simvx.core.text import bundled_font_path
ui = bundled_font_path("ui")
code = bundled_font_path("mono")
The ``"mono"`` face gives every printable ASCII character a 0.6 em body, and the
outlines say so exactly. That holds for characters, not for all of Unicode:
combining marks advance zero, as they must to stack on the letter before them,
and the digraph and fraction characters advance a whole multiple of 0.6 em.
Anything laying out text it did not choose still has to measure it.
The rasterised advances are a weaker promise than the outlines. Glyphs are
loaded hinted, and hinting rounds each advance to a whole pixel independently,
which this face does not always round the same way: at 48 px its ASCII advances
come out 28, 29 and 30 px rather than a uniform 28.8. A layout that places each
character at a pitch of its own is unaffected, because nothing accumulates, but
one that sums advances along a line drifts. Loading unhinted removes it
entirely, at the cost of crispness at small sizes. ``"fallback"``'s DejaVu Sans
Mono rasterises to a single advance at every size tested, which is why it is
also the face a test of a character grid is written against.
Nothing draws in the ``"mono"`` face today, including the code views and the
terminal it is named for. ``Draw2D`` holds one glyph atlas at a time and
``draw_text`` takes no face, so every widget on screen draws in the ``"ui"``
face; selecting a second face needs a text path that carries one per draw on
the desktop renderer and a second baked atlas in the web export.
That has a visible cost, not a deferred one, and it arrived with the ``"ui"``
face becoming proportional. A widget on a character grid derives that grid from
the face it is drawn in and takes the pitch from the widest character that face
draws, which is what keeps a wide glyph out of the column beside it. Under a
proportional face the pitch is therefore the width of ``W`` for every column,
so the grid is correct but sparse: a terminal's cell goes from 8.53 px to
13.13 px at the default 14 px size, the same eighty columns take half as much
width again, and a panel that fits its grid to its own width hands the process
about a third fewer columns than it used to. In a code view it shows as
trailing comments at one source column no longer lining up. A game that needs a
tight grid can draw the whole of itself in a fixed-pitch face by passing
``bundled_font_path("fallback")`` to ``Draw2D.set_font``.
The ``"ui"`` face is proportional: every widget measures the text it holds
against the font's own glyph advances, so a menu, dropdown, tooltip or dialog is
as wide as the string inside it rather than as wide as a guess at a fixed
advance per character.
A project chooses fonts in ``simvx.toml``::
[rendering]
font = "assets/fonts/Inter-Regular.ttf"
fallback_fonts = ["assets/fonts/NotoSansArabic-Regular.ttf"]
prefer_system_fonts = false
``font`` replaces the ``"ui"`` face. ``fallback_fonts`` replaces the chain that
is otherwise auto-detected. ``prefer_system_fonts`` searches the machine's own
fonts ahead of the bundled ones; it is a *preference*, never an exclusion, so
when the search comes up empty the bundled fonts are still used and no
configuration can leave a game with no font. Paths are resolved against the
directory holding ``simvx.toml``, so they can be written the way the project
writes every other asset path.
Each font ships with the licence it may not be redistributed without: the Noto
faces under the SIL Open Font License (``fonts/OFL.txt``), DejaVu under the
Bitstream Vera licence (``fonts/LICENSE-DejaVu.txt``).
"""
import importlib.resources
import logging
from pathlib import Path
from ..asset_resolver import resolve_asset_path
log = logging.getLogger(__name__)
__all__ = [
"BUNDLED_FACES",
"BUNDLED_FONT_FILES",
"bundled_font_licence_path",
"bundled_font_path",
"configured_fallback_fonts",
"configured_font_path",
"prefer_system_fonts",
]
#: Every font file the engine ships, mapped to the licence that must travel with
#: it.
BUNDLED_FONT_FILES: dict[str, str] = {
"NotoSans-Regular.ttf": "OFL.txt",
"NotoSansMono-Regular.ttf": "OFL.txt",
"DejaVuSansMono.ttf": "LICENSE-DejaVu.txt",
}
#: The font file each face is drawn with. Keys are the face names callers pass.
BUNDLED_FACES: dict[str, str] = {
"ui": "NotoSans-Regular.ttf",
"mono": "NotoSansMono-Regular.ttf",
"fallback": "DejaVuSansMono.ttf",
}
_PACKAGE = "simvx.core.text"
_FONT_DIR = "fonts"
def _face_file(face: str) -> str:
"""The font filename for *face*, refusing an unknown name outright.
Silently falling back to a default face would draw a game's code views in a
proportional font and take a release to notice, so a misspelt face is an
error at the call site instead.
"""
try:
return BUNDLED_FACES[face]
except KeyError:
raise ValueError(f"unknown font face {face!r}: expected one of {sorted(BUNDLED_FACES)}") from None
def _resource(name: str) -> Path | None:
"""Resolve a file in the bundled ``fonts/`` directory to a real path, or None."""
try:
traversable = importlib.resources.files(_PACKAGE) / _FONT_DIR / name
return resolve_asset_path(traversable)
except (FileNotFoundError, ModuleNotFoundError, TypeError):
return None
[docs]
def bundled_font_path(face: str) -> str | None:
"""Filesystem path of the shipped font for *face*.
*face* is one of the keys of :data:`BUNDLED_FACES`; there is no default,
because which of them a caller wants is never obvious from the call site.
Returns ``None`` only when the package data is missing, which means a
damaged or hand-trimmed install; callers fall back to a system font search.
"""
filename = _face_file(face)
path = _resource(filename)
if path is None:
log.warning(
"Bundled %s font %s is missing from the installed package; falling back to system fonts.",
face,
filename,
)
return None
return str(path)
[docs]
def bundled_font_licence_path(face: str) -> str | None:
"""Filesystem path of the licence text for *face*'s font, or None if absent."""
path = _resource(BUNDLED_FONT_FILES[_face_file(face)])
return str(path) if path is not None else None
def _rendering_settings(start_dir: str | Path | None = None) -> tuple[dict, Path | None]:
"""``([rendering], the directory holding simvx.toml)``, empty when there is none.
The search starts at *start_dir*, or at the working directory when it is
None. A running game is inside its own project, so the working directory is
right for it; a tool that works on a project it is not standing in, such as
the web exporter, names that project's directory instead. Getting that wrong
is silent: the game draws in one face and its export bakes another.
The one place the font keys are read from, so that a project file which
fails validation is reported once and then ignored by all of them rather
than by each in its own way. A broken ``simvx.toml`` may not change which
font a game draws with, and may not stop it from drawing at all either.
"""
from ..project import ValidationError, find_project, load_project
try:
path = find_project(start_dir)
if path is None:
return {}, None
return load_project(path).rendering, Path(path).parent
except ValidationError as exc:
log.warning(
"simvx.toml is invalid, so its [rendering] font keys were not read (%s); using the bundled fonts.", exc
)
return {}, None
except Exception as exc: # A broken simvx.toml must not break text rendering.
log.debug("Could not read [rendering]: %s", exc)
return {}, None
def _project_font_path(value: object, root: Path | None, key: str) -> str | None:
"""*value* as a readable font path, or None with a warning saying why not.
A named font that is not there is a mistake worth reporting rather than a
reason to draw nothing: the caller carries on with the bundled face, so the
game still has text while the developer fixes the path.
"""
if not isinstance(value, str) or not value:
return None
path = Path(value)
if not path.is_absolute() and root is not None:
path = root / path
if not path.is_file():
log.warning("[rendering] %s = %r does not exist; using the bundled font instead.", key, value)
return None
return str(path)
[docs]
def prefer_system_fonts(start_dir: str | Path | None = None) -> bool:
"""Whether the project asked for system fonts ahead of the bundled ones.
Reads ``[rendering] prefer_system_fonts`` from the nearest ``simvx.toml`` at
or above *start_dir*, or above the working directory when that is None, the
same way display settings are read. Defaults to ``False``.
The value's type is enforced by the project schema rather than coerced here,
because ``bool("false")`` is True and a mistyped switch would otherwise
change the typeface of every string in the game without saying so.
"""
return bool(_rendering_settings(start_dir)[0].get("prefer_system_fonts", False))