Source code for pygfx.objects._text

"""
This module implements the Text and MultiText objects. This is where the text
rendering comes together. Most steps in the text rendering process come from
pygfx.utils.text, though most of the alignment is implemented here.

For details about the text rendering process, see pygfx/utils/text/README.md


## On text object, text blocks, text items, and text pieces

The text object maintains the text. It holds and manages the arrays for the
glyph locations and atlas indices (in its geometry). It is also the main
entrypoint for the user.

Text is divided into multiple blocks, typically one per line/paragraph, similar
to how Qt splits text in blocks in its editor component. This makes it easy to
edit one block, and then merely shift the other blocks to update the layout. In
the multi-text scenario, these blocks can be individually controlled and
positioned by the user.

Each text block again divides its text into multiple items. Each item is a unit
of text that is moved as a whole during layout. Each word typically becomes one
item. Each item is created from one or more text pieces which each can have a
different format (bold, italic, size).

## On layout

Layout is performed on each block, shifting the text items into position based
on text_align, anchor and direction. This positoning is done by offsetting the
item's array of glyph positions. The offset is applied when the item's positions
are copied into the glyph_data buffer.

The Text object also performs a high level layout by positioning the blocks.
The MultiText object does not do this, as the user is responsible for
positioning the blocks.
"""

from typing import List, Union

import numpy as np

from ..resources import Buffer
from ..utils import text as textmodule
from ..utils.bounds import Bounds
from ..utils.enums import TextAlign, TextAnchor
from ..geometries import Geometry
from ..materials import TextMaterial
from ._base import WorldObject


# Allow anchor to be written without a dash, for backward compat.
ANCHOR_ALIASES = {anchor.replace("-", ""): anchor for anchor in TextAnchor}
ANCHOR_ALIASES["center"] = ANCHOR_ALIASES["middle"] = "middle-center"

# We cache the extents of small whitespace strings to improve performance
WHITESPACE_EXTENTS = {}

# This is the dtype for per-glyph data. In the shader we can only use 32bit datatypes (maybe f16 in a future version).
# To safe memory, we combine the atlas_index and format mask into a single u32. Here they look like two uint16 but
# in the shader they're interpreted as a single word, and decomposed.
GLYPH_DTYPE = np.dtype(
    [
        ("pos", "f4", 2),
        ("size", "f4"),
        ("block_index", "u4"),
        ("atlas_index", "u2"),
        ("format", "u2"),  # bitmask encoding relative weight and more
    ]
)


def encode_format(relative_weight, relative_slant):
    """Encode format props into a u16"""
    # Weigth: -250 .. 500, steps of 50  -> 4 bits
    relative_weight = min(max(relative_weight, -250), 500)
    weight_4 = int((relative_weight + 250) / 50 + 0.4999)
    # weight = weight_4 * 50 - 250

    # Slant: -1.75 .. 2, steps of 0.25 -> 4 bits
    relative_slant = min(max(relative_slant, -1.75), 2)
    slant_4 = int((relative_slant + 1.75) * 4 + 0.4999)
    # slant = slant_4 / 4.0 - 1.75;

    # There'res 8 bits left for possible future use

    packed = (weight_4 << 12) + (slant_4 << 8)

    return packed


class TextEngine:
    """A small abstraction that allows subclasses of Text to use a different text engine."""

    def __init__(self):
        # The shaping step, returns (glyph_indices, positions, meta)
        self.shape_text = textmodule._shaper.shape_text_hb

        # The font selection step, returns (text, font_filename) tuples.
        self.select_font = textmodule.select_font

        # The glyph generation step. Returns an array with atlas indices.
        self.generate_glyph = textmodule.generate_glyph

    def get_ws_extent(self, ws, font, direction):
        """Get the extent of a piece of whitespace text. Results of small strings are cached."""
        key = (font.filename, direction)
        map = WHITESPACE_EXTENTS.setdefault(key, {})
        # Try on the full ws
        try:
            return map[ws]
        except KeyError:
            pass
        # Calculate extent on base components
        extent = 0
        for c in ws:
            try:
                extent += map[c]
            except KeyError:
                meta = self.shape_text(c, font.filename, direction)[2]
                map[c] = meta["extent"]
                extent += map[c]
        # Calculate relatively short ws strings (e.g. indentation in code)
        if len(ws) <= 20:
            map[ws] = extent
        return extent


class Text(WorldObject):
    """Render a piece of text.

    Can be used to render a piece of multi-paragraph text. Supports plain text
    as well as formatting with (a subset of) markdown.

    Updates to text are relatively efficient, because text is internally divided
    into text blocks (for lines/paragraphs). Use ``MultiText`` if you want to
    directly control the text blocks.

    Parameters
    ----------
    geometry : Geometry | str
        The geometry on which the ``Text`` will store the buffers required to
        render the text ("positions" and "glyph_data"). If omitted, a new
        geometry is created automatically. This argument can also be used to
        provide the text to render as a string (as an alias for the `text` arg).
    material : TextMaterial
        The text material to use. Can be omitted.
    text : str | list[str]
        The plain text to render (optional). The text is split in one TextBlock per line,
        unless a list is given, in which case each (str) item become a TextBlock.
    markdown : str | list[str]
        The text to render, formatted as markdown (optional).
        See ``set_markdown()`` for details on the supported formatting.
        The text is split in one TextBlock per line,
        unless a list is given, in which case each (str) item become a TextBlock.
    font_size : float
        The size of the font, in object coordinates or pixel screen coordinates,
        depending on the value of the ``screen_space`` property. Default 12.
    family : str, tuple
        The name(s) of the font to prefer.
    direction : str | None
        The text direction overload.
    screen_space : bool
        Whether the text is rendered in model space or in screen space (like a label).
        Default False (i.e. model-space).
    anchor : str | TextAnchor
        The position of the origin of the text. Default "middle-center".
    anchor_offset : float
        The offset (extra margin) for the 'top', 'bottom', 'left', and 'right' anchors.
    max_width : float
        The maximum width of the text. Words are wrapped if necessary. Default zero (no wrapping).
    line_height : float
        A factor to scale the distance between lines. A value of 1 means the
        "native" font's line distance. Default 1.2.
    paragraph_spacing : float
        An extra space between paragraphs. Default 0.
    text_align : str | TextAlign
        The horizontal alignment of the text. Can be "start",
        "end", "left", "right", "center", "justify" or "justify_all". Default
        "start". Text alignment is ignored for vertical text (direction 'ttb' or 'btt').
    text_align_last: str | TextAlign
        The horizontal alignment of the last line of the content element. Default "auto".
    """

    _text_engine = TextEngine()
    is_multi = False

    uniform_type = dict(
        WorldObject.uniform_type,
        rot_scale_transform="2x2xf4",
    )

[docs] def __init__( self, geometry=None, material=None, *, text=None, markdown=None, font_size=12, family=None, direction=None, screen_space=False, anchor="middle-center", anchor_offset=0, max_width=0, line_height=1.2, paragraph_spacing=0, text_align="start", text_align_last="auto", visible: bool = True, render_order: float = 0, name: str = "", ): # Init as a world object if isinstance(geometry, (str, list)): text = text or geometry geometry = None if geometry is None: geometry = Geometry() if material is None: material = TextMaterial() super().__init__( geometry, material, visible=visible, render_order=render_order, name=name, ) # --- check text input text_and_markdown = [i for i in (text, markdown) if i is not None] if len(text_and_markdown) > 1: raise TypeError("Either text or markdown must be given, not both.") # --- create buffers # The position of each text block self.geometry.positions = Buffer(np.zeros((8, 3), "f4")) # self.colors = Buffer(np.zeros((8,4), "f4"))-> we could later implement per-block colors # The per-glyph data, stored with a structured dtype self.geometry.glyph_data = Buffer(np.zeros(16, GLYPH_DTYPE)) # --- init variables to help manage the glyph arrays # The number of allocated glyph slots. # This must be equal to _glyph_indices_top - len(_glyph_indices_gaps) self._glyph_count = 0 # The index marking the maximum used in the arrays. All elements higher than _glyph_indices_top are free. self._glyph_indices_top = 0 # Free slots that are not in the contiguous space at the end of the arrays. self._glyph_indices_gaps = set() # Track what blocks need an update. This set is shared with the TextBlock instances. self._text_blocks = [] # List of TextBlock instances. May not match length of positions. self._dirty_blocks = set() # Set of ints (text_block.index) # --- other geomery-specific things self._aabb = np.zeros((2, 3), "f4") self._aabb_rev = None # --- set propss # Font props self.font_size = font_size self.family = family self.direction = direction self.screen_space = screen_space # Layout props self.anchor = anchor self.anchor_offset = anchor_offset self.max_width = max_width self.line_height = line_height self.paragraph_spacing = paragraph_spacing self.text_align = text_align self.text_align_last = text_align_last # --- set initial content if text is not None: self.set_text(text) elif markdown is not None: self.set_markdown(markdown)
# --- private methods def _update_world_transform(self): # Update when the world transform has changed super()._update_world_transform() # When rendering in screen space, the world transform is used # to establish the point in the scene where the text is placed. # The only part of the local transform that is used is the # position. Therefore, we also keep a transform containing the # local rotation and scale, so that these can be applied to the # text in screen coordinates. # Note that this applies to the whole text, all text blocks rotate around # the text-object origin. To rotate text blocks around their own origin, # we should probably implement TextBlock.angle. matrix = self.local.matrix[:2, :2] self.uniform_buffer.data["rot_scale_transform"] = matrix.T def _update_object(self): super()._update_object() # Is called right before the object is drawn; dirty_blocks = self._dirty_blocks if not dirty_blocks: return # early exit # Update blocks need_high_level_layout = False need_glyph_data_sync = False for index in dirty_blocks: try: block = self._text_blocks[index] except IndexError: continue # block was removed after being marked dirty did_block_layout, did_new_glyph_data = block._update(self) need_high_level_layout |= did_block_layout need_glyph_data_sync |= did_new_glyph_data # Reset dirty_blocks.clear() # Update drawing range. Note that the "gaps" are still rendered. glyph_data_buf = self.geometry.glyph_data glyph_data_buf.draw_range = 0, self._glyph_indices_top # Higher-level layout if need_high_level_layout: self._layout_blocks() # Updating in full turns out to be more efficient than doing all the calls to update_indices if need_glyph_data_sync: glyph_data_buf.update_full() def _layout_blocks(self): total_rect = apply_high_level_layout(self) self._aabb = np.array( [ (total_rect.left, total_rect.bottom, 0), (total_rect.right, total_rect.top, 0), ], "f4", ) # --- font properties @property def font_size(self): """The font size. For text rendered in screen space (``screen_space`` property is set), the size is in logical pixels, and the object's local transform affects the final text size. For text rendered in world space (``screen_space`` property is *not* set), the size is in object coordinates, and the the object's world-transform affects the final text size. Note that font size is indicative only. Final glyph size further depends on the font family, as glyphs may be smaller (or larger) than the indicative size. Final glyph size may further vary based on additional formatting applied a particular subsection. """ return self._font_size @font_size.setter def font_size(self, value): self._font_size = float(value) self._trigger_blocks_update(layout=True) # only need re-layout @property def family(self): """The font family to use. The name(s) of the font to prefer. If multiple names are given, they are preferred in the given order. Characters that are not supported by any of the given fonts are rendered with the default font (from the Noto Sans collection). """ return self._font_props.family @family.setter def family(self, family): if family is None: self._font_props = textmodule.FontProps() else: self._font_props = textmodule.FontProps(family) self._trigger_blocks_update(render_glyphs=True) @property def direction(self): """The font direction overload. If not set (i.e. set to None), the text direction is determined automatically based on the script, selecting either 'ltr' or 'rtl' (Asian scripts are rendered left-to-right by default). If set, valid values are 'ltr', 'rtl', 'ttb', or 'btt'. Can also specify two values, separated by a dash (e.g. 'ttb-rtl') to specify the word-direction and line-direction, respectively. """ return self._direction @direction.setter def direction(self, direction): direction = direction or "" word_direction, _, line_direction = direction.partition("-") valid_directions = ("", "ltr", "rtl", "ttb", "btt") if not ( word_direction in valid_directions and line_direction in valid_directions ): raise ValueError( "Text direction components must be None, 'ltr', 'rtl', 'ttb', or 'btt'." ) if not word_direction and not line_direction: self._direction = "" elif word_direction and line_direction: self._direction = f"{word_direction}-{line_direction}" elif not line_direction: m = {"ltr": "ttb", "rtl": "ttb", "ttb": "rtl", "btt": "rtl"} line_direction = m[word_direction] self._direction = f"{word_direction}-{line_direction}" else: self._direction = f"{word_direction}-{line_direction}" self._trigger_blocks_update(render_glyphs=True) @property def screen_space(self): """Whether to render text in screen space. * False: Render in model-pace (making it part of the scene), and having a bounding box. * True: Render in screen-space (like a label). Note that in both cases, the local object's rotation and scale will still transform the text. """ return self._store.screen_space @screen_space.setter def screen_space(self, screen_space): self._store.screen_space = bool(screen_space) self._trigger_blocks_update(layout=True) # --- layout properties @property def anchor(self): """The position of the origin of the text. Represented as a string representing the vertical and horizontal anchors, separated by a dash, e.g. "top-left" or "bottom-center". * Vertical values: "top", "middle", "baseline", "bottom". * Horizontal values: "left", "center", "right". See :obj:`pygfx.utils.enums.TextAnchor`: """ return self._anchor @anchor.setter def anchor(self, anchor): # Init if anchor is None: anchor = "middle-center" elif not isinstance(anchor, str): raise TypeError("Text anchor must be str.") anchor = ANCHOR_ALIASES.get(anchor, anchor) anchor = anchor.lower().strip().replace("-", "_") if anchor not in TextAnchor.__fields__: raise ValueError(f"Text anchor must be one of {TextAnchor}. Got {anchor!r}") self._anchor = TextAnchor[anchor] self._trigger_blocks_update(layout=True) @property def anchor_offset(self): """The offset (extra margin) for the 'top', 'bottom', 'left', and 'right' anchors.""" return self._anchor_offset @anchor_offset.setter def anchor_offset(self, value): self._anchor_offset = float(value) self._trigger_blocks_update(layout=True) @property def max_width(self): """The maximum width of the text. Text will wrap if beyond this limit. The coordinate system that this applies to is the same as for ``font_size``. Set to 0 for no wrap (default). For vertical text, this value indicates the maximum height. """ return self._max_width @max_width.setter def max_width(self, width): self._max_width = float(width or 0) self._trigger_blocks_update(layout=True) @property def line_height(self): """The relative height of a line of text. Used to set the distance between lines. Default 1.2. For vertical text this also defines the distance between the (vertical) lines. """ return self._line_height @line_height.setter def line_height(self, height): self._line_height = float(height or 1.2) self._trigger_blocks_update(layout=True) @property def paragraph_spacing(self): """The extra margin between two paragraphs. Measured in text units (like line height and font size). """ return self._paragraph_spacing @paragraph_spacing.setter def paragraph_spacing(self, paragraph_spacing): self._paragraph_spacing = float(paragraph_spacing or 0) self._trigger_blocks_update(layout=True) @property def text_align(self): """Set the alignment of wrapped text. Default 'start'. See :obj:`pygfx.utils.enums.TextAlign`: Text alignment is ignored for vertical text (direction 'ttb' or 'btt'). """ return self._text_align @text_align.setter def text_align(self, align): if align is None: align = "start" if not isinstance(align, str): raise TypeError("Text align must be a None or str.") align = align.lower().replace("-", "_") if align not in TextAlign.__fields__: raise ValueError(f"Text align must be one of {TextAlign}. Got {align!r}.") if align == "auto": align = "start" self._text_align = TextAlign[align] self._trigger_blocks_update(layout=True) @property def text_align_last(self): """Set the alignment of the last line of text. Default "auto". See :obj:`pygfx.utils.enums.TextAlign`: Text alignment is ignored for vertical text (direction 'ttb' or 'btt'). """ return self._text_align_last @text_align_last.setter def text_align_last(self, align): if align is None: align = "auto" if not isinstance(align, str): raise TypeError("Text align_last must be a None or str.") align = align.lower().replace("-", "_") if align not in TextAlign.__fields__: raise ValueError( f"Text align_last must be one of {TextAlign}. Got {align!r}" ) self._text_align_last = TextAlign[align] self._trigger_blocks_update(layout=True) # --- public methods def set_text(self, text: Union[str, List[str]]): """Set the full text. Each line (i.e. paragraph) results in one TextBlock, unless a list is given, in which case each (str) item become a TextBlock. On subsequent calls, blocks are re-used, and blocks that did not change have near-zero overhead (only lines/blocks that changed need updating). """ if isinstance(text, str): str_per_block = text.splitlines() elif isinstance(text, list): str_per_block = text else: raise TypeError("Text text should be str.") self._ensure_text_block_count(len(str_per_block)) for i, s in enumerate(str_per_block): # Note that setting the blocks text is fast if it did not change self._text_blocks[i].set_text(s) # Do a layout now, so the bounding box is up-to-date self._update_object() def set_markdown(self, text: Union[str, List[str]]): """Set the full text, formatted as markdown. The supported markdown features are: * ``**bold** and *italic* text`` is supported for words, word-parts, and (partial) sentences, but not multiple lines (formatting state does not cross line boundaries). * ``# h1``, ``## h2``, ``### h3``, etc. * ``* bullet points``. Each line (i.e. paragraph) results in one TextBlock, unless a list is given, in which case each (str) item become a TextBlock. On subsequent calls, blocks are re-used, and blocks that did not change have near-zero overhead (only lines/blocks that changed need updating). """ if isinstance(text, str): str_per_block = text.splitlines() elif isinstance(text, list): str_per_block = text else: raise TypeError("Text markdown should be str.") self._ensure_text_block_count(len(str_per_block)) for i, s in enumerate(str_per_block): self._text_blocks[i].set_markdown(s) # Do a layout now, so the bounding box is up-to-date self._update_object() def _get_bounds_from_geometry(self): screen_space = self._store["screen_space"] if not self._text_blocks: return None elif screen_space: # There is no sensible bounding box for text in screen space, except # for the anchor point. Although the point has no volume, it does # contribute to e.g. the scene's bounding box. return Bounds(np.zeros((2, 3), "f4")) else: # A bounding box makes sense, and we calculated it during layout, # because we're already shifting rects there. return Bounds(self._aabb) # --- block management def _trigger_blocks_update(self, layout=False, render_glyphs=False): for block in self._text_blocks: block._mark_dirty(layout=layout, render_glyphs=render_glyphs) def _ensure_text_block_count(self, n): """Allocate new buffer if necessary.""" # Make sure the underlying buffers are large enough current_buffer_size = self.geometry.positions.nitems max_oversize = max(4 * n, 8) if current_buffer_size < n or current_buffer_size > max_oversize: new_size = 2 ** int(np.ceil(np.log2(max(n, 1)))) new_size = max(8, new_size) self._allocate_block_buffers(new_size) if n == len(self._text_blocks): pass elif len(self._text_blocks) < n: # Add blocks while len(self._text_blocks) < n: block = TextBlock(len(self._text_blocks), self._dirty_blocks) self._text_blocks.append(block) else: # Remove blocks self.geometry.glyph_data.update_full() # cleared blocks, means cleared glyph indices while len(self._text_blocks) > n: block = self._text_blocks.pop(-1) block._clear(self) def _allocate_block_buffers(self, n): """Allocate new buffers for text blocks with the given size.""" smallest_n = min(n, len(self._text_blocks)) new_positions = np.zeros((n, 3), "f4") new_positions[:smallest_n] = self.geometry.positions.data[:smallest_n] self.geometry.positions = Buffer(new_positions) # --- glyph array management def _glyphs_allocate(self, n): max_glyph_slots = self.geometry.glyph_data.nitems # Need larger buffer? n_free = max_glyph_slots - self._glyph_count if n > n_free: self._glyphs_create_new_buffers(n) # Allocate indices if not self._glyph_indices_gaps: # Contiguous: indices is a range assert self._glyph_indices_top == self._glyph_count indices = range(self._glyph_indices_top, self._glyph_indices_top + n) self._glyph_count += n self._glyph_indices_top += n else: # First use gaps ... indices = np.empty((n,), "u4") n_from_gap = min(n, len(self._glyph_indices_gaps)) for i in range(n_from_gap): indices[i] = self._glyph_indices_gaps.pop() self._glyph_count += n_from_gap # Then use indices at the end n -= n_from_gap if n > 0: indices[n_from_gap:] = range( self._glyph_indices_top, self._glyph_indices_top + n ) self._glyph_count += n self._glyph_indices_top += n return indices def _glyphs_deallocate(self, indices): # These glyphs will still end up in the vertex shader, # but it will discard early by producing degeneate triangles. # Clear data, for sanity self.geometry.glyph_data.data[indices] = 0 # self.glyph_data.update_indices(indices) -> update_full is called from _update_object when needed # Deallocate self._glyph_count -= len(indices) # Small optimization to avoid gaps if indices.min() == self._glyph_indices_top - len(indices): self._glyph_indices_top -= len(indices) else: self._glyph_indices_gaps.update(indices) # TODO: Reduce buffer sizes from the Text object, re-packing all items # # Maybe reduce buffer size # max_glyph_slots = self.glyph_data.nitems # if self._glyph_count < 0.25 * max_glyph_slots: # self._glyphs_create_new_buffers() def _glyphs_create_new_buffers(self, extra_needed=0): assert extra_needed >= 0 # Get new size need_size = self._glyph_indices_top + extra_needed new_size = 2 ** int(np.ceil(np.log2(need_size))) new_size = max(16, new_size) # Prepare new arrays glyph_data = np.zeros(new_size, GLYPH_DTYPE) # Copy data over n = self._glyph_indices_top glyph_data[:n] = self.geometry.glyph_data.data[:n] # Store self.geometry.glyph_data = Buffer(glyph_data) class MultiText(Text): """Render multiple pieces of text. The ``MultiText`` object manages a collection of ``TextBlock`` objects, for which the text (or markdown) can be individually set, and which can be individually positioned. Each text block has the same layout support as the ``Text`` obect. Most properties are defined by the text object, i.e. shared across all ``TextBlock`` instances of that ``MultiText`` object. But this may change in the future to allow more flexibility. """ is_multi = True def set_text_block_count(self, n): """Set the number of text blocks to n. Use this if you want to use text blocks directly and you know how many blocks you need beforehand. After this, get access to the blocks using ``get_text_block()``. """ self._ensure_text_block_count(n) def get_text_block_count(self): """Get how many text blocks this MultiText object has.""" return len(self._text_blocks) def create_text_block(self): """Create a text block and return it. The text block count is increased by one. """ self._ensure_text_block_count(len(self._text_blocks) + 1) return self._text_blocks[-1] def create_text_blocks(self, n): """Create n text blocks and return as a list. The text block count is increased by n. """ self._ensure_text_block_count(len(self._text_blocks) + n) return self._text_blocks[-n:] def get_text_block(self, index): """Get the TextBlock instance at the given index. The block's position is stored in ``text_object.geometry.positions.data[index]``. """ return self._text_blocks[index] def _layout_blocks(self): total_rect = Rect() for block in self._text_blocks: total_rect.left = min(total_rect.left, block._rect.left) total_rect.right = max(total_rect.right, block._rect.right) total_rect.top = max(total_rect.top, block._rect.top) total_rect.bottom = min(total_rect.bottom, block._rect.bottom) self._aabb = np.array( [ (total_rect.left, total_rect.bottom, 0), (total_rect.right, total_rect.top, 0), ], "f4", ) def _get_bounds_from_geometry(self): screen_space = self._store["screen_space"] if screen_space: positions_buf = self.geometry.positions if not self._text_blocks: return None if self._aabb_rev == positions_buf.rev: return Bounds(self._aabb) aabb = None # Get positions and check expected shape positions = positions_buf.data[: len(self._text_blocks)] aabb = np.array([positions.min(axis=0), positions.max(axis=0)], "f4") # If positions contains xy, but not z, assume z=0 if aabb.shape[1] == 2: aabb = np.column_stack([aabb, np.zeros((2, 1), "f4")]) self._aabb = aabb self._aabb_rev = positions_buf.rev return Bounds(self._aabb) else: # A bounding box makes sense, and we calculated it during layout, # because we're already shifting rects there. return Bounds(self._aabb) class TextBlock: """The TextBlock represents one block or paragraph of text. Users can obtain instances of this class from the ``MultiText`` object. Text blocks are positioned using an entry in ``text_object.geometry.positions``. The ``Text`` object uses text blocks internally to do efficient re-layout when text is updated. With the ``MultiText`` object the text blocks are positioned by the user or external code. """ def __init__(self, index, dirty_blocks): self._index = index # e.g. the index in geometry.positions self._dirty_blocks = dirty_blocks # a set from the Text/MultiText object self._input = None self._need_layout = False self._need_render_glyphs = False self._pending_position = None self._text_items = [] self._old_text_items = [] # Used by layout self._nlines = 0 self._rect = Rect() @property def index(self): """The index in the geometry.positions buffer.""" return self._index def _mark_dirty(self, *, layout=False, render_glyphs=False): # Trigger ._update() being called right before the next draw self._dirty_blocks.add(self._index) self._need_layout |= bool(layout) | bool(render_glyphs) self._need_render_glyphs |= bool(render_glyphs) def _update(self, text_ob): """Do the work to bring this block up-to-date. Be fast!""" # Update position? if self._pending_position is not None: positions = text_ob.geometry.positions positions.data[self.index] = self._pending_position positions.update_indices(self.index) self._pending_position = None # Reset flags # self._dirty_blocks.discard(self._index) # no, Text object calls clear need_render_glyphs = self._need_render_glyphs need_layout = self._need_layout need_glyph_data_upload = False self._need_render_glyphs = False self._need_layout = False # De-allocate old item objects if self._old_text_items: need_glyph_data_upload = True for item in self._old_text_items: item.clear(text_ob) self._old_text_items = [] # Quick exit if not (need_render_glyphs or need_layout): return False, False # Update in-use item objects for item in self._text_items: if need_render_glyphs or item.need_render_glyphs: item.render_glyphs(text_ob) # Layout if need_layout: apply_block_layout(text_ob, self) # Item updates, and layout, may require syncing glyph data for item in self._text_items: if item.need_sync_with_geometry: need_glyph_data_upload = True item.sync_with_geometry(text_ob, self._index) return need_layout, need_glyph_data_upload # i.e. did_layout def _clear(self, text_ob): if self._old_text_items: for item in self._old_text_items: item.clear(text_ob) self._old_text_items = [] for item in self._text_items: item.clear(text_ob) self._text_items = [] self._input = None def set_position(self, x, y, z=0): self._mark_dirty() self._pending_position = float(x), float(y), float(z) def set_text(self, text: str): """Set the text for this TextBlock (as a string). This is called from ``Text.set_text()``, but can also be called directly. Note that in contrast to ``Text.set_text()``, setting the text on a TextBlock does not result in a re-layout (i.e. the bounding box is not updated until after the next draw). """ if not isinstance(text, str): raise TypeError("TextBlock text should be str.") input = "text", text if input == self._input: return self._input = input self._mark_dirty(layout=True) # Split text in words text_parts = textmodule.tokenize_text(text) self._text_parts_to_items(text_parts) def set_markdown(self, text: str): """Set the markdown for this TextBlock (as a string). This is called from ``Text.set_markdown()``, but can also be called directly. Note that in contrast to ``Text.set_markdown()``, setting the text on a TextBlock does not result in a re-layout (i.e. the bounding box is not updated until after the next draw). """ if not isinstance(text, str): raise TypeError("TextBlock markdown should be str.") input = "md", text if input == self._input: return self._input = input self._mark_dirty(layout=True) # Split text in parts using a tokenizer text_parts = list(textmodule.tokenize_markdown(text)) is_newline = True is_bold = is_italic = 0 max_i = len(text_parts) - 1 for i in range(0, len(text_parts)): kind, text = text_parts[i] # Detect bullets if is_newline and i < max_i and text_parts[i + 1][0] == "ws": if text in ("*", "-"): text_parts[i] = "bullet", " • " # ltr and rtl compatible text_parts[i + 1] = "ws", "" # remove whitespace if text.startswith("#") and len(text.strip("#")) == 0: header_level = len(text) text_parts[i] = f"fmt:h{header_level}", text text_parts[i + 1] = "ws", "" # remove whitespace if kind == "nl": is_newline = True elif kind != "ws": is_newline = False # Detect bold / italics if kind == "stars": # Get what surrounding parts look like prev_is_wordlike = next_is_wordlike = False if i > 0: prev_is_wordlike = text_parts[i - 1][0] != "ws" if i < max_i: next_is_wordlike = text_parts[i + 1][0] != "ws" # Decide how to format if not prev_is_wordlike and next_is_wordlike: # Might be a beginning if text == "**": text_parts[i] = "fmt:+b", text is_bold += 1 elif text == "*": text_parts[i] = "fmt:+i", text is_italic += 1 elif prev_is_wordlike and not next_is_wordlike: # Might be an end if text == "**": text_parts[i] = "fmt:-b", text is_bold = max(0, is_bold - 1) elif text == "*": text_parts[i] = "fmt:-i", text is_italic = max(0, is_italic - 1) elif prev_is_wordlike and next_is_wordlike: if text == "**": if is_bold: text_parts[i] = "fmt:-b", text is_bold = max(0, is_bold - 1) else: text_parts[i] = "fmt:+b", text is_bold += 1 elif text == "*" and is_italic: if is_italic: text_parts[i] = "fmt:-i", text is_italic = max(0, is_italic - 1) else: text_parts[i] = "fmt:+i", text is_italic += 1 # Produce text items self._text_parts_to_items(text_parts) def _text_parts_to_items(self, iter_of_kind_text): # A TextItem represents one "word"; one thing held together during layout. # Multiple pieces can go into one TextItem, mostly when the word has multiple different formatting in it. pending_whitespace = "" pending_pieces = [] new_items = [] def flush_pieces(force=False): nonlocal pending_whitespace if pending_pieces or force: if self._text_items: item = self._text_items.pop(0) else: item = TextItem() item.set_text_pieces(tuple(pending_pieces)) item.ws_before = pending_whitespace pending_pieces.clear() new_items.append(item) pending_whitespace = "" # In the code below, we resolve format-modifiers to an 'absolute' format. # These are both implementation details, but let's document them here for clarity: # # Modifiers: # +b: make bold # -b: unbold # +i: make italic # -i: make italic # h1 h2 h3 h4: headers format = {} bold_level = italic_level = 0 # Process the parts to create TextItem objects for kind, text in iter_of_kind_text: if kind.startswith("fmt:"): modifier = kind.partition(":")[-1] if modifier == "+b": bold_level += 1 format["weight"] = 300 # weight can be -250 .. 500 elif modifier == "-b": bold_level -= 1 if not bold_level: format.pop("weight", None) elif modifier == "+i": italic_level += 1 format["slant"] = 1 # slant can be -1.75 .. 2.00 elif modifier == "-i": italic_level -= 1 if not italic_level: format.pop("slant", None) elif modifier.startswith("h"): level = int(modifier[1:]) format["size"] = [1, 2.0, 1.5, 1.25][level] if level <= 3 else 1.1 elif kind == "ws": flush_pieces() pending_whitespace += text elif kind == "nl": format.clear() bold_level = italic_level = 0 flush_pieces() if pending_whitespace or not new_items: flush_pieces(force=True) new_items[-1].nl_after += text else: pending_pieces.append((format.copy(), text)) flush_pieces() if pending_whitespace: flush_pieces(force=True) # Store old items that need to be de-allocated for item in self._text_items: if len(item.glyph_indices): self._old_text_items.append(item) # Store new worlds self._text_items = new_items class TextItem: """Represents one unit of text that moves as a whole to a new line when wrapped (usually a word). This is a low-level internal object (not public). """ __slots__ = [ "ascender", "atlas_indices", "descender", "direction", "extent", "formats", "glyph_count", "glyph_indices", "layout_offset", "margin_before", "need_render_glyphs", "need_sync_with_geometry", "nl_after", "offset", "pieces", "positions", "sizes", "ws_before", ] def __init__(self): # The text defines the arrays self.pieces = None # Whitespace attributes affect layout self.ws_before = "" self.nl_after = "" self.margin_before = 0 # is ws_before expressed as a float (in font units) # Flags to control precise updates self.need_render_glyphs = False self.need_sync_with_geometry = False # The text item has its own per-glyph arrays. These are copied into the geometries buffer arrays. self.atlas_indices = None self.positions = None self.sizes = None self.formats = None self.glyph_count = 0 # The indices for slots in the glyph_data buffer. This value is managed by the Text object. self.glyph_indices = np.zeros((0,), "u4") # Transform info when copying into the glyph_data buffer. Set during layout. self.offset = (1.0, 0.0, 0.0) self.layout_offset = None # used by layout as a temp var # Metadata self.extent = 0 self.ascender = 0 self.descender = 0 self.direction = None def set_text_pieces(self, pieces): # The pieces arg should be [(format, text), (format, text), ...] if pieces != self.pieces: self.pieces = pieces self.need_render_glyphs = True self.margin_before = 0 def set_offset(self, scale, dx, dy): offset = scale, dx, dy if offset != self.offset: self.offset = offset self.need_sync_with_geometry = True def render_glyphs(self, text_ob): """Update the item's arrays.""" self.need_render_glyphs = False self.need_sync_with_geometry = True font_props = text_ob._font_props textengine = text_ob._text_engine self.margin_before = 0 # The direction may be forced by the text object. If the text-object's direction is # None (default), the first piece that is word-like will determine the direction # for this text item. direction = text_ob._direction.partition("-")[0] or None if not self.pieces: self.atlas_indices = None self.positions = None self.sizes = None self.formats = None self.glyph_count = 0 if self.ws_before: font = textengine.select_font(" ", font_props)[0][1] self.margin_before = textengine.get_ws_extent( self.ws_before, font, direction ) return # Prepare containers for array atlas_indices_list = [] positions_list = [] sizes_list = [] formats_list = [] # Init meta data extent = ascender = descender = 0 calculate_margin_before = bool(self.ws_before) # Text rendering steps: font selection, shaping, glyph generation last_reverse_index = 0 for format, text2 in self.pieces: rsize = format.get("size", 1.0) format_mask = encode_format(format.get("weight", 0), format.get("slant", 0)) for text, font in textengine.select_font(text2, font_props): unicode_indices, positions, meta = textengine.shape_text( text, font.filename, direction ) atlas_indices = textengine.generate_glyph( unicode_indices, font.filename ) n = atlas_indices.shape[0] if rsize != 1.0: positions *= rsize if extent: positions[:, 0] += extent # put pieces next to each-other sizes = np.full(n, rsize, "f4") formats = np.full(n, format_mask, "u2") extent = extent + meta["extent"] * rsize ascender = max(ascender, meta["ascender"] * rsize) descender = min(descender, meta["descender"] * rsize) # is neg # The first piece that has actual words (not numbers or punctuation) defines direction if direction is None and meta["script"]: direction = meta["direction"] # Put in list, take direction into account if direction in ("rtl", "btt"): atlas_indices_list.insert(last_reverse_index, atlas_indices) positions_list.insert(last_reverse_index, positions) sizes_list.insert(last_reverse_index, sizes) formats_list.insert(last_reverse_index, formats) else: atlas_indices_list.append(atlas_indices) positions_list.append(positions) sizes_list.append(sizes) formats_list.append(formats) last_reverse_index = len(atlas_indices_list) # Calculate margin_before based on the font and direction of the first piece if calculate_margin_before: calculate_margin_before = False self.margin_before = textengine.get_ws_extent( self.ws_before, font, direction ) # Store meta data on the item self.extent = extent self.ascender = ascender self.descender = descender self.direction = direction # Can be None # Store as a single array if len(atlas_indices_list) == 0: self.atlas_indices = None self.positions = None self.sizes = None self.formats = None self.glyph_count = 0 elif len(atlas_indices_list) == 1: self.atlas_indices = atlas_indices_list[0] self.positions = positions_list[0] self.sizes = sizes_list[0] self.formats = formats_list[0] self.glyph_count = len(self.positions) else: self.atlas_indices = np.concatenate(atlas_indices_list, axis=0) self.positions = np.concatenate(positions_list, axis=0) self.sizes = np.concatenate(sizes_list, axis=0) self.formats = np.concatenate(formats_list, axis=0) self.glyph_count = len(self.positions) def sync_with_geometry(self, text_ob, block_index): """Sync the item's arrays into the geometries buffers.""" self.need_sync_with_geometry = False if self.glyph_count != len(self.glyph_indices): self._allocate_indices(text_ob) if self.glyph_count > 0: self._sync_data(text_ob, block_index) def _allocate_indices(self, text_ob): glyph_count = self.glyph_count glyph_indices = self.glyph_indices current_glyph_indices_count = len(glyph_indices) if glyph_count < current_glyph_indices_count: new_indices = glyph_indices[:glyph_count].copy() indices_to_free = glyph_indices[glyph_count:] text_ob._glyphs_deallocate(indices_to_free) self.glyph_indices = new_indices elif glyph_count > current_glyph_indices_count: extra_indices = text_ob._glyphs_allocate( glyph_count - current_glyph_indices_count ) new_indices = np.empty((glyph_count,), "u4") new_indices[:current_glyph_indices_count] = glyph_indices new_indices[current_glyph_indices_count:] = extra_indices self.glyph_indices = new_indices def _sync_data(self, text_ob, block_index): indices = self.glyph_indices # TODO: I think we may optimize by combining arrays from multiple glyphs # Make the positioning absolute scale, dx, dy = self.offset positions = self.positions * scale + (dx, dy) sizes = self.sizes * scale # Get buffers from geometry (the getattr is a bit expensive) glyph_data = text_ob.geometry.glyph_data # Get subset and mark indices for upload glyph_data_array = glyph_data.data # glyph_data.update_indices(indices) -> doing a full upload is faster (done in _update_object) # Write data glyph_data_array["pos"][indices] = positions glyph_data_array["size"][indices] = sizes glyph_data_array["atlas_index"][indices] = self.atlas_indices glyph_data_array["block_index"][indices] = block_index glyph_data_array["format"][indices] = self.formats def clear(self, text_ob): if len(self.glyph_indices): text_ob._glyphs_deallocate(self.glyph_indices) self.glyph_indices = np.zeros((0,), "u4") class Rect: __slots__ = ["bottom", "left", "right", "top"] def __init__(self, left=0.0, right=0.0, top=0.0, bottom=0.0): self.left = left self.right = right self.top = top self.bottom = bottom def __repr__(self): return f"<Rect({self.left:0.5g}, {self.right:0.5g}, {self.top:0.5g}, {self.bottom:0.5g})>" @property def width(self): return self.right - self.left @property def height(self): return self.top - self.bottom def shift(self, dx, dy): self.left = self.left + dx self.right = self.right + dx self.top = self.top + dy self.bottom = self.bottom + dy def get_offset_for_anchor(self, anchor, anchor_offset): v_anchor, _, h_anchor = anchor.partition("-") if h_anchor == "left": dx = -self.left + anchor_offset elif h_anchor == "right": dx = -self.right - anchor_offset else: # center or justify dx = -0.5 * (self.left + self.right) if v_anchor == "top": dy = -self.top - anchor_offset elif v_anchor == "baseline": dy = -anchor_offset elif v_anchor == "bottom": dy = -self.bottom + anchor_offset else: # middle dy = -0.5 * (self.top + self.bottom) return dx, dy # ----- Layout functions def apply_block_layout(text_ob, text_block): """The layout step. Updates positions and sizes to finalize the geometry.""" items = text_block._text_items if not items: text_block._nlines = 1 # an empty line also takes the space of ine line text_block._rect = Rect() return # Obtain layout attributes font_size = text_ob._font_size line_height = text_ob._line_height * font_size # like CSS paragraph_spacing = text_ob._paragraph_spacing * font_size max_width = text_ob._max_width anchor = text_ob._anchor text_align = text_ob._text_align text_align_last = text_ob._text_align_last anchor_offset = text_ob._anchor_offset # Get the direction to apply word_direction, _, line_direction = text_ob._direction.partition("-") line_direction = line_direction or "ttb" # If not overriden by the text_ob, the block direction is defined by the direction of the # first item that has a direction. Only items with actual words/script have a direction. # (Thanks to Harfbuz we can actually distinguish between script / no-script.) ordered_items = items if not word_direction: # Find direction for item in items: if item.direction: word_direction = item.direction break word_direction = word_direction or "ltr" # Re-order to allow subsentences in other scripts. # Note that in this case we only have ltr and rtl, because vertical # text only happens when forced by the text_ob, and then everything is the same direction. ordered_items = [] i = 0 for item in items: if item.direction and item.direction != word_direction: ordered_items.insert(i, item) else: ordered_items.append(item) i = len(ordered_items) word_direction_is_horizontal = word_direction in ("ltr", "rtl") line_direction_is_vertical = line_direction in ("ttb", "btt") # Resolve text_align_last if text_align_last == "auto": if text_align == "justify": text_align_last = "start" elif text_align == "justify_all": text_align_last = "justify" else: text_align_last = text_align if text_align == "justify_all": text_align = "justify" # Resolve text align to real directions if line_direction_is_vertical: text_align_map = {"start": "left", "end": "right"} if word_direction == "rtl": text_align_map = {"start": "right", "end": "left"} text_align = text_align_map.get(text_align, text_align) text_align_last = text_align_map.get(text_align_last, text_align_last) else: # For horizonal line direction (i.e. vertical word-direction) # we don't support text align. text_align = text_align_last = "left" if line_direction == "ltr" else "right" # If we wrote the above correctly, we have a clear subset of alignment vars assert text_align in ("left", "right", "center", "justify") assert text_align_last in ("left", "right", "center", "justify") # Prepare # The offset is used to track the position as we wrap the text offset = [0, 0] # The current rect represents the bounding box of each line, relative to the line. # Vertically, point zero is at the baseline, and the rect spans from ascender (top, positive) to descender (bottom, negative). current_rect = Rect() # left, right, top, bottom # The current line holds the text items for the line being processed. current_line = [] lines_rects = [] # List[ Tuple[ List[TextItem], Rect] ] def make_new_line(n_new_lines=1, n_new_paragraphs=0): nonlocal current_line, current_rect # Update position skip = n_new_lines * line_height + n_new_paragraphs * paragraph_spacing if line_direction == "ttb": offset[1] -= skip offset[0] = 0 elif line_direction == "btt": offset[1] += skip offset[0] = 0 elif line_direction == "rtl": offset[1] = 0 offset[0] -= skip elif line_direction == "ltr": offset[1] = 0 offset[0] += skip # Add the line if current_line: lines_rects.append((current_line, current_rect)) current_line = [] current_rect = Rect() # Resolve position and sizes for item in ordered_items: # Get item width and determine if we need a new line apply_whitespace_margin = True if max_width > 0 and current_line: if word_direction_is_horizontal: current_width = current_rect.width else: current_width = current_rect.height item_width = (item.margin_before + item.extent) * font_size if current_width + item_width > max_width: make_new_line() apply_whitespace_margin = False if word_direction == "ltr": # Update offset if apply_whitespace_margin: offset[0] += item.margin_before * font_size item.layout_offset = tuple(offset) offset[0] += item.extent * font_size # Update rect current_rect.left = 0 current_rect.right = offset[0] current_rect.top = max(current_rect.top, item.ascender * font_size) current_rect.bottom = min(current_rect.bottom, item.descender * font_size) elif word_direction == "rtl": # Update offset if apply_whitespace_margin: offset[0] -= item.margin_before * font_size offset[0] -= item.extent * font_size item.layout_offset = tuple(offset) # Update rect current_rect.left = offset[0] current_rect.right = 0 current_rect.top = max(current_rect.top, item.ascender * font_size) current_rect.bottom = min(current_rect.bottom, item.descender * font_size) elif word_direction == "ttb": # Update offset if apply_whitespace_margin: offset[1] -= item.margin_before * font_size item.layout_offset = tuple(offset) offset[1] -= item.extent * font_size # Update rect current_rect.top = 0 current_rect.bottom = offset[1] current_rect.left = item.descender * font_size current_rect.right = item.ascender * font_size elif word_direction == "btt": # Update offset if apply_whitespace_margin: offset[1] += item.margin_before * font_size offset[1] += item.extent * font_size item.layout_offset = tuple(offset) # Update rect current_rect.top = offset[1] current_rect.bottom = 0 current_rect.left = item.descender * font_size current_rect.right = item.ascender * font_size current_line.append(item) # The item can have newlines too. Does not happen when using text_ob.set_text(), # but can happen when using TextBlock.set_text(). if item.nl_after: make_new_line(len(item.nl_after), 1) # Properly end the loop if current_line: make_new_line() # Calculate block rect. The top is positive, the bottom is negative (descender). block_rect = Rect() for line, rect in lines_rects: # For rtl, align each line so left is at the origin if word_direction == "rtl": shift = -rect.left rect.shift(shift, 0) for item in line: layout_offset = item.layout_offset item.layout_offset = layout_offset[0] + shift, layout_offset[1] # Aggregate if line_direction_is_vertical: block_rect.left = min(block_rect.left, rect.left) block_rect.right = max(block_rect.right, rect.right) else: block_rect.top = max(block_rect.top, rect.top) block_rect.bottom = min(block_rect.bottom, rect.bottom) first_rect, last_rect = lines_rects[0][1], lines_rects[-1][1] if line_direction == "ttb": block_rect.top = first_rect.top block_rect.bottom = offset[1] + line_height + last_rect.bottom elif line_direction == "btt": block_rect.top = offset[1] - line_height + last_rect.top block_rect.bottom = first_rect.bottom elif line_direction == "rtl": block_rect.left = offset[0] + line_height + last_rect.left block_rect.right = first_rect.right elif line_direction == "ltr": block_rect.left = first_rect.left block_rect.right = offset[0] - line_height + last_rect.right if text_align == "justify" or text_align_last == "justify": if max_width > 0: block_rect.right = max_width else: # Within a block, support justify without max-width max_width = block_rect.right # Determine horizontal anchor if text_ob.is_multi: # Full layout done here, including anchoring. anchor_offset_x, anchor_offset_y = block_rect.get_offset_for_anchor( anchor, anchor_offset ) else: # If the text_ob does its layout, it's far easier to *not* do the anchoring here, # except to anchor according to text alignment. anchor_offset_x, anchor_offset_y = block_rect.get_offset_for_anchor( f"baseline-{text_align}", 0 ) # Shift block rect for anchoring block_rect.shift(anchor_offset_x, anchor_offset_y) # Align the text, i.e. shift individual lines so they fit inside the block rect according to the current alignment num_lines = len(lines_rects) align = text_align for i, (line, rect) in enumerate(lines_rects): if i == num_lines - 1: align = text_align_last line_length = rect.right - rect.left block_length = block_rect.right - block_rect.left extra_space_per_word = 0 if not word_direction_is_horizontal: line_offset_x = 0 elif align == "justify": line_offset_x = 0 length_to_add = max_width - line_length nwords = len(line) if nwords > 1: extra_space_per_word = length_to_add / (nwords - 1) elif align == "center": line_offset_x = 0.5 * block_length - 0.5 * line_length elif align == "right": line_offset_x = block_length - line_length else: # elif align == "left": line_offset_x = 0 for j, item in enumerate(line): if word_direction == "rtl": j = len(line) - j - 1 dx = ( item.layout_offset[0] + anchor_offset_x + line_offset_x + j * extra_space_per_word ) dy = item.layout_offset[1] + anchor_offset_y item.set_offset(font_size, dx, dy) # Update block's rect. Used by the final layout and to calculate bounding boxes. text_block._rect = block_rect text_block._nlines = num_lines def apply_high_level_layout(text_ob): text_blocks = text_ob._text_blocks if not text_blocks: text_ob._aabb = np.zeros((2, 3), "f4") return font_size = text_ob._font_size anchor = text_ob._anchor anchor_offset = text_ob._anchor_offset line_height = text_ob._line_height * font_size # like CSS par_spacing = text_ob._paragraph_spacing * font_size # Get line direction. line_direction = text_ob._direction.partition("-")[2] or "ttb" line_direction_is_vertical = line_direction in ("ttb", "btt") # Calculate offsets to put the blocks beneath each-other, as well as the full rect. # Note that the distance between anchor points is independent on the anchor-mode. offsets = np.zeros((len(text_blocks),), "f4") offset = 0 total_rect = Rect() if line_direction == "ttb": for i, block in enumerate(text_blocks): rect = block._rect offsets[i] = offset offset -= max(block._nlines * line_height, 0.5 * rect.height) + par_spacing total_rect.left = min(total_rect.left, rect.left) total_rect.right = max(total_rect.right, rect.right) total_rect.top = text_blocks[0]._rect.top total_rect.bottom = offsets[-1] + text_blocks[-1]._rect.bottom elif line_direction == "btt": for i, block in enumerate(text_blocks): rect = block._rect offsets[i] = offset offset += max(block._nlines * line_height, 0.5 * rect.height) + par_spacing total_rect.left = min(total_rect.left, rect.left) total_rect.right = max(total_rect.right, rect.right) total_rect.top = offsets[-1] + text_blocks[-1]._rect.top total_rect.bottom = text_blocks[0]._rect.bottom elif line_direction == "rtl": for i, block in enumerate(text_blocks): rect = block._rect offsets[i] = offset offset -= max(block._nlines * line_height, 0.5 * rect.width) + par_spacing total_rect.bottom = min(total_rect.bottom, rect.bottom) total_rect.top = max(total_rect.top, rect.top) total_rect.left = offsets[-1] + text_blocks[-1]._rect.left total_rect.right = text_blocks[0]._rect.right elif line_direction == "ltr": for i, block in enumerate(text_blocks): rect = block._rect offsets[i] = offset offset += max(block._nlines * line_height, 0.5 * rect.width) + par_spacing total_rect.bottom = min(total_rect.bottom, rect.bottom) total_rect.top = max(total_rect.top, rect.top) total_rect.left = text_blocks[0]._rect.left total_rect.right = offsets[-1] + text_blocks[-1]._rect.right # Get anchor offset # Note that the anchoring is dead-simple because the blocks are anchoring based on text_align. anchor_offset_x, anchor_offset_y = total_rect.get_offset_for_anchor( anchor, anchor_offset ) # Shift bounding box rect, and store for geomerty bounding box. # Note how we swap top and bottom here, because bottom has smaller values than top. total_rect.shift(anchor_offset_x, anchor_offset_y) # Update positions positions = text_ob.geometry.positions if line_direction_is_vertical: positions.data[: len(offsets), 0] = anchor_offset_x positions.data[: len(offsets), 1] = offsets + anchor_offset_y else: positions.data[: len(offsets), 0] = offsets + anchor_offset_x positions.data[: len(offsets), 1] = anchor_offset_y # Update full positions buffer to avoid overhead of chunking logic. Measurably faster. positions.update_full() # positions.update_range(0, len(offsets)) return total_rect