From c2673a43fe12ae0a5e765b7655db6bc6813e08a0 Mon Sep 17 00:00:00 2001 From: Paul V Craven Date: Mon, 5 Oct 2026 15:57:30 -0500 Subject: [PATCH] Fix create_text_sprite atlas sharing, anchors, position, and empty text - The texture was created with the text as its atlas name, so sprites with the same text shared one atlas image. Creating a second one in another color or size redrew the shared image (changing the first sprite) into the first sprite's region (stretching the second). Give each texture a unique name; the atlas frees it when the texture is garbage collected. - Only y was shifted before drawing into the texture, so with anchor_x="center" or "right" the text started at a negative x and was partly or completely clipped. Shift x too. - The sprite's center was read from the Text after shifting it, which put the sprite too high. Use the bounds from before the shift, so the sprite covers the same area as an equivalent Text object. - An empty string made a 0x0 texture and raised ValueError. Make the texture at least 1x1. - Round the texture size up instead of truncating it. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 ++- arcade/text.py | 26 +++++++--- tests/unit/text/test_text_sprite.py | 75 +++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3d094f1d6..4098bb4acd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,12 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. ## Unreleased -- No unreleased changes yet +### Fixes +- Fixed several `arcade.create_text_sprite()` bugs: + - Sprites with the same text shared one image in the texture atlas, so creating a second one (for example in another color or size) changed the first one and drew the second stretched. Each sprite now gets its own image. + - `anchor_x="center"` cut off the left half of the text, and `anchor_x="right"` made an empty sprite. + - The sprite was placed too high. It now covers the same area as an `arcade.Text` with the same arguments at the same position. + - An empty string raised `ValueError`. It now makes a transparent sprite, as documented. ## 4.0.0.dev8 diff --git a/arcade/text.py b/arcade/text.py index 161e267430..88b083c6f9 100644 --- a/arcade/text.py +++ b/arcade/text.py @@ -2,8 +2,10 @@ Drawing text with pyglet label """ +import math from pathlib import Path from typing import Any +from uuid import uuid4 import pyglet from pyglet.enums import Style, Weight @@ -913,12 +915,23 @@ def create_text_sprite( multiline=multiline, ) + # Where the text is with its anchor at (0, 0), as a Text object would be + left = text_object.left + bottom = text_object.bottom + # At least 1 pixel, so an empty string still makes a (transparent) texture size = ( - int(text_object.right - text_object.left), - int(text_object.top - text_object.bottom), + max(1, math.ceil(text_object.right - left)), + max(1, math.ceil(text_object.top - bottom)), ) - text_object.y = -text_object.bottom - texture = arcade.Texture.create_empty(text, size) + + # Draw it into the texture with its bottom left corner at (0, 0) + text_object.x = -left + text_object.y = -bottom + + # Each sprite needs its own image in the atlas. A name based on the text + # would make sprites with the same text, but different colors or sizes, + # share one. + texture = arcade.Texture.create_empty(f"create_text_sprite_{uuid4().hex}", size) if not texture_atlas: texture_atlas = arcade.get_window().ctx.default_atlas @@ -927,10 +940,11 @@ def create_text_sprite( fbo.clear(color=background_color or arcade.color.TRANSPARENT_BLACK) text_object.draw() + # Place the sprite where the Text object was drawn return arcade.Sprite( texture, - center_x=text_object.right - (size[0] / 2), - center_y=text_object.top, + center_x=left + size[0] / 2, + center_y=bottom + size[1] / 2, ) diff --git a/tests/unit/text/test_text_sprite.py b/tests/unit/text/test_text_sprite.py index 01b93f05ec..812843f1f7 100644 --- a/tests/unit/text/test_text_sprite.py +++ b/tests/unit/text/test_text_sprite.py @@ -8,3 +8,78 @@ def test_create(window, color): assert isinstance(sprite, arcade.Sprite) assert sprite.width == pytest.approx(75, rel=10) assert sprite.height == pytest.approx(20, rel=5) + + +def _ink(image): + """Total alpha of an image, as a measure of how much was drawn""" + return sum(value * count for value, count in enumerate(image.getchannel("A").histogram())) + + +def _sprite_bounds(sprite): + return sprite.left, sprite.right, sprite.bottom, sprite.top + + +def _text_bounds(text): + return text.left, text.right, text.bottom, text.top + + +@pytest.mark.parametrize( + "kwargs", + [ + {}, + {"anchor_x": "center"}, + {"anchor_x": "right"}, + {"font_size": 40}, + {"multiline": True, "width": 120}, + {"multiline": True, "width": 90, "align": "center", "anchor_x": "center"}, + ], +) +def test_position_matches_text(window, kwargs): + """The sprite covers the same area as a Text object at the same position""" + text = "Two lines of text gj" + sprite = arcade.create_text_sprite(text, **kwargs) + text_object = arcade.Text(text, 0, 0, anchor_y="baseline", **kwargs) + assert _sprite_bounds(sprite) == pytest.approx(_text_bounds(text_object), abs=1) + + +@pytest.mark.parametrize("anchor_x", ["left", "center", "right"]) +def test_anchor_draws_all_text(window, anchor_x): + """Every anchor puts all of the text inside the texture""" + atlas = window.ctx.default_atlas + # Read each image right away, so this doesn't depend on the sprites + # having separate images (tested below) + anchored = arcade.create_text_sprite("Hello World", font_size=20, anchor_x=anchor_x) + actual = _ink(atlas.read_texture_image_from_atlas(anchored.texture)) + left = arcade.create_text_sprite("Hello World", font_size=20) + expected = _ink(atlas.read_texture_image_from_atlas(left.texture)) + assert expected > 0 + assert actual == pytest.approx(expected, rel=0.01) + + +def test_same_text_different_style(window): + """Sprites with the same text keep their own images""" + atlas = window.ctx.default_atlas + small_red = arcade.create_text_sprite("Same", color=arcade.color.RED, font_size=20) + red_image = atlas.read_texture_image_from_atlas(small_red.texture) + + big_blue = arcade.create_text_sprite("Same", color=arcade.color.BLUE, font_size=40) + + # Each texture has an atlas region of its own size + for sprite in (small_red, big_blue): + region = atlas.get_texture_region_info(sprite.texture.atlas_name) + assert (region.width, region.height) == sprite.texture.size + assert big_blue.texture.size != small_red.texture.size + + # Creating the second sprite didn't change the first one's image + assert atlas.read_texture_image_from_atlas(small_red.texture).tobytes() == red_image.tobytes() + red, _green, blue, _alpha = red_image.getextrema() + assert red[1] > 200 and blue[1] == 0 + + +def test_empty_text(window): + """An empty string makes a transparent sprite instead of failing""" + sprite = arcade.create_text_sprite("") + assert sprite.texture.width >= 1 + assert sprite.texture.height >= 1 + image = window.ctx.default_atlas.read_texture_image_from_atlas(sprite.texture) + assert _ink(image) == 0