"""
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