diff --git a/CHANGELOG.md b/CHANGELOG.md index df7911706..d837b1113 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/arcade/text.py b/arcade/text.py index f6cc44f19..452c50700 100644 --- a/arcade/text.py +++ b/arcade/text.py @@ -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: @@ -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 diff --git a/tests/unit/text/test_text_fonts.py b/tests/unit/text/test_text_fonts.py new file mode 100644 index 000000000..4f54767ab --- /dev/null +++ b/tests/unit/text/test_text_fonts.py @@ -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(())