Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page.
- `bold` strings such as `"light"` or `"semibold"` were all treated as bold, including `"normal"`. They now set that font weight, and the `bold` property returns the weight name for weights other than bold and normal.
- A `pyglet.text.LinearGradient` color raised `TypeError` in `Text`, `draw_text()`, `create_text_sprite()`, and `UILabel.update_font()`. Gradients now work, and the `color` property returns the gradient.
- The `Text` constructor ignored every error while creating the label, so bad arguments such as an unknown keyword or an invalid `font_name` only raised later, the first time the text was used. Only the "no window yet" case is deferred now.
- Fixed `font_name` paths to font files in `arcade.Text`, `draw_text()`, `create_text_sprite()`, and the `Text.font_name` setter. A path or resource handle such as `":resources:fonts/ttf/Custom.ttf"` used the file name as the font name, so the text was drawn in the default font. The file is now loaded the first time it's used, and its family name is used. Lists of font names, which the docs allow, raised `TypeError` and now work, and a path no longer takes priority over names listed before it.

### New Features
- Added `arcade.sweep_sprite(sprite, dx, dy, sprite_list)`, which checks the whole path of a moving sprite and returns a `SweepInfo` for the first sprite it would hit (the `sprite`, how far along the move as a `fraction` and `distance`, and the surface `normal`), or `None`. Fast sprites can't pass through thin walls this way. A sprite that already overlaps one is an immediate hit. Added the `sprite_bullets_sweep` example comparing it with a plain collision check.
Expand Down
127 changes: 87 additions & 40 deletions arcade/text.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,61 +42,111 @@ def load_font(path: str | Path) -> None:
# Load a font using a custom resource handle
arcade.load_font(":font:Custom.ttf")

You can also pass the path to a font file as ``font_name``,
which loads the file the first time it's used.

Args:
path: Path to the font file
Raises:
FileNotFoundError: if the font specified wasn't found
"""
file_path = resolve(path)
pyglet.font.add_file(str(file_path))
_load_font_file(resolve(path))


FontNameOrNames = str | tuple[str, ...]

# Family names of the font files loaded so far, so each file is only
# loaded once, and a path can be used as a font name
_font_file_families: dict[Path, str | None] = {}

def _attempt_font_name_resolution(font_name: FontNameOrNames) -> str:
"""Attempt to resolve a font name.

Preserves the original logic of this section, even though it
doesn't seem to make sense entirely. Comments are an attempt
to make sense of the original code.

If it can't resolve a definite path, it will return the original
argument for pyglet to attempt to resolve. This is consistent with
the original behavior of this code before it was encapsulated.
def _load_font_file(path: Path) -> str | None:
"""
Load a font file, if it isn't loaded yet, and return its family name.

Args:
font_name: A font name, path to a font file, or list of names
Returns ``None`` if the family name couldn't be found.
"""
if font_name:
# ensure
if isinstance(font_name, str):
font_list: tuple[str, ...] = (font_name,)
elif isinstance(font_name, tuple):
font_list = font_name
else:
raise TypeError(
"font_name parameter must be a string, "
"or a tuple of strings that specify a font name."
)
if path in _font_file_families:
return _font_file_families[path]

for font in font_list:
# pyglet reports the families it added with the name the platform
# uses for them, which is the name to load the font with
families: list[str] = []

def on_font_loaded(family_name, weight, style, stretch):
families.append(family_name)

pyglet.font.manager.push_handlers(on_font_loaded=on_font_loaded)
try:
pyglet.font.add_file(str(path))
finally:
pyglet.font.manager.remove_handlers(on_font_loaded=on_font_loaded)

family: str | None = families[0] if families else None
if family is None:
# The file was already loaded some other way, for example with
# pyglet.font.add_file(), so read the name from the file itself
from pyglet.font.ttf import TruetypeInfo

try:
info = TruetypeInfo(str(path))
except Exception:
pass
else:
try:
path = resolve(font)
# print(f"Font path: {path=}")
family = info.get_name("family")
finally:
info.close()

# found a font successfully!
return path.name
_font_file_families[path] = family
return family

except FileNotFoundError:
pass

# failed to find it ourselves, hope pyglet can make sense of it
# Note this is the best approximation of what I understand the old
# behavior to have been.
return pyglet.font.load(font_list).name
def _font_file_family(font_name: str) -> str | None:
"""
If a font name is the path to a font file, load it and return its
family name. Otherwise, return ``None``.
"""
try:
path = resolve(font_name)
except FileNotFoundError:
# Not a file, or a resource that doesn't exist
return None
except OSError:
# Some font names aren't valid paths at all, but a resource
# handle should still report what's wrong with it
if font_name.strip().startswith(":"):
raise
return None
if not path.is_file():
return None
return _load_font_file(path)


raise ValueError(f"Couldn't find a font for {font_name!r}")
def _attempt_font_name_resolution(font_name: FontNameOrNames) -> str:
"""Resolve a font name, path, or list of them to the name of one font.

Paths to font files are loaded and replaced by the font's family
name. The first name pyglet finds is returned, or pyglet's default
font if it finds none.

Args:
font_name: A font name, path to a font file, or a tuple or list
of them.
"""
if isinstance(font_name, str):
font_list: tuple[str, ...] = (font_name,)
elif isinstance(font_name, (tuple, list)):
font_list = tuple(font_name)
else:
raise TypeError(
"font_name parameter must be a string, or a tuple of strings that specify a font name."
)
if not font_list or not all(font_list):
raise ValueError(f"Couldn't find a font for {font_name!r}")

names = [_font_file_family(font) or font for font in font_list]
return pyglet.font.load(names).name


def _draw_pyglet_label(label: pyglet.text.Label) -> None:
Expand Down Expand Up @@ -502,10 +552,7 @@ def font_name(self, font_name: FontNameOrNames) -> None:
if font_name == self._requested_font_name and label.font_name == self._resolved_font_name:
return
label = self._layout_label()
if isinstance(font_name, str):
label.font_name = font_name
else:
label.font_name = list(font_name)
label.font_name = _attempt_font_name_resolution(font_name)
self._requested_font_name = font_name
self._resolved_font_name = label.font_name

Expand Down
123 changes: 123 additions & 0 deletions tests/unit/text/test_text_fonts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
"""Tests for font names, paths to font files, and lists of them"""

import pyglet
import pytest

import arcade
import arcade.text

KENNEY = "Kenney Future"
KENNEY_HANDLE = ":system:fonts/ttf/Kenney/Kenney_Future.ttf"


@pytest.fixture
def font_families(monkeypatch) -> dict:
"""Forget which font files were loaded, so each test starts fresh"""
families: dict = {}
monkeypatch.setattr(arcade.text, "_font_file_families", families, raising=False)
return families


def _render(window, text: arcade.Text):
"""Draw a Text into an offscreen framebuffer and return its pixels"""
ctx = window.ctx
fbo = ctx.framebuffer(color_attachments=[ctx.texture((400, 100), components=4)])
with fbo.activate():
fbo.clear(color=(0, 0, 0, 0))
text.draw()
return fbo.read(components=4)


def _make(font_name) -> arcade.Text:
return arcade.Text("Hello World", 10, 30, font_name=font_name, font_size=30)


def _paths() -> list:
path = arcade.resources.resolve(KENNEY_HANDLE)
return [KENNEY_HANDLE, str(path)]


@pytest.mark.parametrize("font_name", _paths())
def test_path_uses_the_font_in_the_file(window, font_families, font_name):
text = _make(font_name)
assert text.label.font_name == KENNEY
assert _render(window, text) == _render(window, _make(KENNEY))


@pytest.mark.parametrize("font_name", _paths())
def test_font_name_setter_accepts_path(window, font_families, font_name):
text = _make("Liberation Sans")
text.font_name = font_name
assert text.label.font_name == KENNEY
assert _render(window, text) == _render(window, _make(KENNEY))


def test_path_in_list(window, font_families):
text = _make(("NoSuchFontXYZ", KENNEY_HANDLE, "Liberation Sans"))
assert text.label.font_name == KENNEY


def test_first_available_font_is_used(window, font_families):
assert _make(("NoSuchFontXYZ", "Liberation Sans", KENNEY)).label.font_name == "Liberation Sans"
assert _make(("Liberation Sans", KENNEY_HANDLE)).label.font_name == "Liberation Sans"


def test_missing_resource_falls_back_to_next_name(window, font_families):
text = _make((":resources:fonts/ttf/NoSuchFont.ttf", "Liberation Sans"))
assert text.label.font_name == "Liberation Sans"


def test_unknown_resource_handle_raises(window, font_families):
with pytest.raises(KeyError):
_make(":nosuchhandle:fonts/NoSuchFont.ttf")


def test_list_of_names(window, font_families):
text = _make(["NoSuchFontXYZ", "Liberation Sans"]) # type: ignore
assert text.label.font_name == "Liberation Sans"


# Tests draw_text, which warns that it's slow
@pytest.mark.filterwarnings("ignore::arcade.exceptions.PerformanceWarning")
def test_draw_text_list_and_path(window, font_families):
window.ctx.label_cache.clear()
arcade.draw_text("Hello", 10, 10, font_name=["NoSuchFontXYZ", "Liberation Sans"]) # type: ignore
arcade.draw_text("Hello", 10, 10, font_name=KENNEY_HANDLE)
fonts = sorted(text.label.font_name for text in window.ctx.label_cache.values())
assert fonts == [KENNEY, "Liberation Sans"]


def test_each_file_is_loaded_once(window, font_families, monkeypatch):
added = []
add_file = pyglet.font.add_file

def counting_add_file(path):
added.append(path)
add_file(path)

monkeypatch.setattr(pyglet.font, "add_file", counting_add_file)
for _ in range(3):
_make(KENNEY_HANDLE)
arcade.load_font(KENNEY_HANDLE)
assert len(added) == 1


def test_file_loaded_elsewhere(window, font_families):
"""A file already added to pyglet directly reports no new fonts, so the
family name is read from the file"""
path = arcade.resources.resolve(KENNEY_HANDLE)
pyglet.font.add_file(str(path))
assert _make(str(path)).label.font_name == KENNEY
assert font_families[path] == KENNEY


def test_load_font_then_name(window, font_families):
arcade.load_font(KENNEY_HANDLE)
assert _make(KENNEY).label.font_name == KENNEY


def test_empty_name_raises(window, font_families):
with pytest.raises(ValueError):
_make("")
with pytest.raises(ValueError):
_make(())
Loading