'''
Text
====
An abstraction of text creation. Depending of the selected backend, the
accuracy of text rendering may vary.
.. versionchanged:: 1.5.0
:attr:`LabelBase.line_height` added.
.. versionchanged:: 1.0.7
The :class:`LabelBase` does not generate any texture if the text has a
width <= 1.
'''
__all__ = ('LabelBase', 'Label')
import re
import os
from kivy import kivy_data_dir
from kivy.graphics.texture import Texture
from kivy.core import core_select_lib
from kivy.resources import resource_find
from kivy.compat import PY2
DEFAULT_FONT = 'DroidSans'
FONT_REGULAR = 0
FONT_ITALIC = 1
FONT_BOLD = 2
FONT_BOLDITALIC = 3
[docs]class LabelBase(object):
'''Core text label.
This is the abstract class used by different backends to render text.
.. warning::
The core text label can't be changed at runtime. You must recreate one.
:Parameters:
`font_size`: int, defaults to 12
Font size of the text
`font_name`: str, defaults to DEFAULT_FONT
Font name of the text
`bold`: bool, defaults to False
Activate "bold" text style
`italic`: bool, defaults to False
Activate "italic" text style
`text_size`: tuple, defaults to (None, None)
Add constraint to render the text (inside a bounding box).
If no size is given, the label size will be set to the text size.
`padding`: float, defaults to None
If it's a float, it will set padding_x and padding_y
`padding_x`: float, defaults to 0.0
Left/right padding
`padding_y`: float, defaults to 0.0
Top/bottom padding
`halign`: str, defaults to "left"
Horizontal text alignment inside the bounding box
`valign`: str, defaults to "bottom"
Vertical text alignment inside the bounding box
`shorten`: bool, defaults to False
Indicate whether the label should attempt to shorten its textual
contents as much as possible if a `size` is given.
Setting this to True without an appropriately set size will lead to
unexpected results.
`max_lines`: int, defaults to 0 (unlimited)
If set, this indicate how maximum line are allowed to render the
text. Works only if a limitation on text_size is set.
`mipmap` : bool, defaults to False
Create a mipmap for the texture
.. versionchanged:: 1.8.0
`max_lines` parameters has been added.
.. versionchanged:: 1.0.8
`size` have been deprecated and replaced with `text_size`.
.. versionchanged:: 1.0.7
The `valign` is now respected. This wasn't the case previously
so you might have an issue in your application if you have not
considered this.
'''
__slots__ = ('options', 'texture', '_label', '_text_size')
_cache_glyphs = {}
_fonts = {}
_fonts_cache = {}
_texture_1px = None
def __init__(self, text='', font_size=12, font_name=DEFAULT_FONT,
bold=False, italic=False, halign='left', valign='bottom',
shorten=False, text_size=None, mipmap=False, color=None,
line_height=1.0, **kwargs):
options = {'text': text, 'font_size': font_size,
'font_name': font_name, 'bold': bold, 'italic': italic,
'halign': halign, 'valign': valign, 'shorten': shorten,
'mipmap': mipmap, 'line_height': line_height}
options['color'] = color or (1, 1, 1, 1)
options['padding'] = kwargs.get('padding', 0)
options['padding_x'] = kwargs.get('padding_x', options['padding'])
options['padding_y'] = kwargs.get('padding_y', options['padding'])
if 'size' in kwargs:
options['text_size'] = kwargs['size']
else:
if text_size is None:
options['text_size'] = (None, None)
else:
options['text_size'] = text_size
text_width, text_height = options['text_size']
if text_width is not None:
self._text_size = (
text_width - options['padding_x'] * 2,
text_height)
else:
self._text_size = options['text_size']
self._text = options['text']
self._internal_height = 0
self.options = options
self.texture = None
self.resolve_font_name()
@staticmethod
[docs] def register(name, fn_regular, fn_italic=None, fn_bold=None,
fn_bolditalic=None):
'''Register an alias for a Font.
.. versionadded:: 1.1.0
If you're using a ttf directly, you might not be able to use the
bold/italic properties of
the ttf version. If the font is delivered in multiple files
(one regular, one italic and one bold), then you need to register these
files and use the alias instead.
All the fn_regular/fn_italic/fn_bold parameters are resolved with
:func:`kivy.resources.resource_find`. If fn_italic/fn_bold are None,
fn_regular will be used instead.
'''
fonts = []
for font_type in fn_regular, fn_italic, fn_bold, fn_bolditalic:
if font_type is not None:
font = resource_find(font_type)
if font is None:
raise IOError('File {0}s not found'.format(font_type))
else:
fonts.append(font)
else:
fonts.append(fonts[-1]) # add regular font to list again
LabelBase._fonts[name] = tuple(fonts)
def resolve_font_name(self):
options = self.options
fontname = options['font_name']
fonts = self._fonts
fontscache = self._fonts_cache
# is the font is registered ?
if fontname in fonts:
# return the prefered font for the current bold/italic combinaison
italic = int(options['italic'])
if options['bold']:
bold = FONT_BOLD
else:
bold = FONT_REGULAR
options['font_name_r'] = fonts[fontname][italic | bold]
elif fontname in fontscache:
options['font_name_r'] = fontscache[fontname]
else:
filename = resource_find(fontname)
if filename is None:
# XXX for compatibility, check directly in the data dir
filename = os.path.join(kivy_data_dir, fontname)
if not os.path.exists(filename):
raise IOError('Label: File %r not found' % fontname)
fontscache[fontname] = filename
options['font_name_r'] = filename
[docs] def get_extents(self, text):
'''Return a tuple (width, height) indicating the size of the specified
text'''
return (0, 0)
def _render_begin(self):
pass
def _render_text(self, text, x, y):
pass
def _render_end(self):
pass
def shorten(self, text, margin=2):
# Just a tiny shortcut
textwidth = self.get_extents
if self.text_size[0] is None:
width = 0
else:
width = int(self.text_size[0])
letters = '_..._' + text
while textwidth(letters)[0] > width:
letters = letters[:letters.rfind(' ')]
max_letters = len(letters) - 2
segment = (max_letters // 2)
if segment - margin > 5:
segment -= margin
return type(text)('{0}...{1}').format(text[:segment].strip(),
text[-segment:].strip())
else:
segment = max_letters - 3 # length of '...'
return type(text)('{0}...').format(text[:segment].strip())
[docs] def render(self, real=False):
'''Return a tuple (width, height) to create the image
with the user constraints.
2 differents methods are used:
* if the user does not set the width, split the line
and calculate max width + height
* if the user sets a width, blit per glyph
'''
options = self.options
render_text = self._render_text
get_extents = self.get_extents
uw, uh = self.text_size
max_lines = int(options.get('max_lines', 0))
w, h = 0, 0
x, y = 0, 0
if real:
self._render_begin()
halign = options['halign']
valign = options['valign']
if valign == 'bottom':
y = self.height - self._internal_height
elif valign == 'middle':
y = int((self.height - self._internal_height) / 2)
else:
self._internal_height = 0
# no width specified, faster method
if uw is None:
index = 0
for line in self.text.split('\n'):
index += 1
if max_lines > 0 and index > max_lines:
break
lw, lh = get_extents(line)
lh = lh * options['line_height']
if real:
x = 0
if halign[0] == 'c':
# center
x = int((self.width - lw) / 2.)
elif halign[0] == 'r':
# right
x = int(self.width - lw)
if len(line):
render_text(line, x, y)
y += int(lh)
else:
w = max(w, int(lw))
self._internal_height += int(lh)
h = self._internal_height if uh is None else uh
# constraint
else:
# precalculate id/name
if not self.fontid in self._cache_glyphs:
self._cache_glyphs[self.fontid] = {}
cache = self._cache_glyphs[self.fontid]
if not real:
# verify that each glyph have size
glyphs = list(set(self.text)) + ['.']
for glyph in glyphs:
if not glyph in cache:
cache[glyph] = get_extents(glyph)
# Shorten the text that we actually display
text = self.text
last_word_width = get_extents(text[text.rstrip().rfind(' '):])[0]
if (options['shorten'] and
get_extents(text)[0] > uw - last_word_width):
text = self.shorten(text)
# first, split lines
glyphs = []
lines = []
lw = lh = 0
for word in re.split(r'( |\n)', text):
# calculate the word width
ww, wh = 0, 0
if word == '':
ww, wh = get_extents(' ')
for glyph in word:
gw, gh = cache[glyph]
ww += gw
wh = max(gh, wh)
wh = wh * options['line_height']
# is the word fit on the uw ?
if ww > uw:
lines.append(((ww, wh), 0, word))
lw = lh = x = 0
if max_lines > 0 and len(lines) >= max_lines:
break
continue
# get the maximum height for this line
lh = max(wh, lh)
# is the word fit on the line ?
if (word == '\n' or x + ww > uw) and lw != 0:
# no, push actuals glyph
# lw, lh), is_last_line, glyphs)
last_line = 1 if word == '\n' else 0
lines.append(((lw, lh), last_line, glyphs))
glyphs = []
# reset size
lw = lh = x = 0
# new line ? don't render
if word == '\n' or word == ' ':
continue
# advance the width
lw += ww
x += ww
lh = max(wh, lh)
glyphs += list(word)
# got some char left ?
if lw != 0:
lines.append(((lw, lh), 1, glyphs))
# ensure the number of lines is not more than the user asked
if max_lines > 0:
lines = lines[:max_lines]
if not real:
self._internal_height = sum([size[1] for size, last_line,
glyphs in lines])
ll_h = lines[-1][0][1]
lh_offset = ll_h - (ll_h / self.options['line_height'])
self._internal_height = self._internal_height - lh_offset
h = self._internal_height if uh is None else uh
w = uw
else:
# really render now.
for size, last_line, glyphs in lines:
x = 0
if halign[0] == 'c':
# center
x = int((self.width - size[0]) / 2.)
elif halign[0] == 'r':
# right
x = int(self.width - size[0])
# justification
just_space = 0
if halign[-1] == 'y':
# justified
if glyphs and not last_line:
x = 0
last_space = 1 if glyphs[-1] == ' ' else 0
_spaces = glyphs.count(' ') - last_space
# divide left over space between `spaces`
# TODO implement a better method of stretching
# glyphs?
if _spaces:
sw = cache[' '][0] if last_space else 0
just_space = (((uw - size[0] + sw) * 1.) /
(_spaces * 1.))
for glyph in glyphs:
lw, lh = cache[glyph]
if glyph == ' ':
x += just_space
elif glyph != '\n':
render_text(glyph, x, y)
x += lw
y += size[1]
if not real:
# was only the first pass
# return with/height
w = int(max(w, 1))
h = int(max(h, 1))
return w, h
# get data from provider
data = self._render_end()
assert(data)
# If the text is 1px width, usually, the data is black.
# Don't blit that kind of data, otherwise, you have a little black bar.
if data is not None and data.width > 1:
self.texture.blit_data(data)
def _texture_refresh(self, *l):
self.refresh()
def _texture_fill(self, texture):
# second pass, render for real
self.render(real=True)
[docs] def refresh(self):
'''Force re-rendering of the text
'''
self.resolve_font_name()
# first pass, calculating width/height
sz = self.render()
self._size_texture = sz
self._size = (sz[0] + self.options['padding_x'] * 2,
sz[1] + self.options['padding_y'] * 2)
# if no text are rendered, return nothing.
width, height = self._size
if width <= 1 or height <= 1:
self.texture = self.texture_1px
return
# create a delayed texture
texture = self.texture
if texture is None or \
width != texture.width or \
height != texture.height:
texture = Texture.create(size=(width, height),
mipmap=self.options['mipmap'],
callback=self._texture_fill)
texture.flip_vertical()
texture.add_reload_observer(self._texture_refresh)
self.texture = texture
else:
texture.ask_update(self._texture_fill)
def _get_text(self):
if PY2:
try:
if type(self._text) is unicode:
return self._text
return self._text.decode('utf8')
except AttributeError:
# python 3 support
return str(self._text)
except UnicodeDecodeError:
return self._text
else:
return self._text
def _set_text(self, text):
if text != self._text:
self._text = text
text = property(_get_text, _set_text, doc='Get/Set the text')
label = property(_get_text, _set_text, doc='Get/Set the text')
@property
def texture_1px(self):
if LabelBase._texture_1px is None:
tex = Texture.create(size=(1, 1), colorfmt='rgba')
tex.blit_buffer(b'\x00\x00\x00\x00')
LabelBase._texture_1px = tex
return LabelBase._texture_1px
@property
def size(self):
return self._size
@property
def width(self):
return self._size[0]
@property
def height(self):
return self._size[1]
@property
[docs] def content_width(self):
'''Return the content width'''
if self.texture is None:
return 0
return self.texture.width + 2 * self.options['padding_x']
@property
[docs] def content_height(self):
'''Return the content height'''
if self.texture is None:
return 0
return self.texture.height + 2 * self.options['padding_y']
@property
[docs] def content_size(self):
'''Return the content size (width, height)'''
if self.texture is None:
return (0, 0)
return (self.content_width, self.content_height)
@property
[docs] def fontid(self):
'''Return a unique id for all font parameters'''
return str([self.options[x] for x in (
'font_size', 'font_name_r', 'bold', 'italic')])
def _get_text_size(self):
return self._text_size
def _set_text_size(self, x):
self._text_size = x
text_size = property(_get_text_size, _set_text_size,
doc='''Get/set the (width, height) of the '
'contrained rendering box''')
usersize = property(_get_text_size, _set_text_size,
doc='''(deprecated) Use text_size instead.''')
# Load the appropriate provider
Label = core_select_lib('text', (
('pygame', 'text_pygame', 'LabelPygame'),
('sdlttf', 'text_sdlttf', 'LabelSDLttf'),
('pil', 'text_pil', 'LabelPIL'),
))
if 'KIVY_DOC' not in os.environ:
if not Label:
from kivy.logger import Logger
import sys
Logger.critical('App: Unable to get a Text provider, abort.')
sys.exit(1)
# For the first initalization, register the default font
Label.register('DroidSans',
'data/fonts/DroidSans.ttf',
'data/fonts/DroidSans-Italic.ttf',
'data/fonts/DroidSans-Bold.ttf',
'data/fonts/DroidSans-BoldItalic.ttf')