Coverage for lib/lottie/utils/font.py: 20%
578 statements
« prev ^ index » next coverage.py v7.2.2, created at 2023-03-20 16:17 +0100
« prev ^ index » next coverage.py v7.2.2, created at 2023-03-20 16:17 +0100
1import os
2import sys
3import subprocess
4import fontTools.pens.basePen
5import fontTools.ttLib
6import fontTools.t1Lib
7from fontTools.pens.boundsPen import ControlBoundsPen
8import enum
9import math
10from xml.etree import ElementTree
11from ..nvector import NVector
12from ..objects.bezier import Bezier, BezierPoint
13from ..objects.shapes import Path, Group, Fill, Stroke
14from ..objects.text import TextJustify
15from ..objects.base import LottieProp, CustomObject
16from ..objects.layers import ShapeLayer
19class BezierPen(fontTools.pens.basePen.BasePen):
20 def __init__(self, glyphSet, offset=NVector(0, 0)):
21 super().__init__(glyphSet)
22 self.beziers = []
23 self.current = Bezier()
24 self.offset = offset
26 def _point(self, pt):
27 return self.offset + NVector(pt[0], -pt[1])
29 def _moveTo(self, pt):
30 self._endPath()
32 def _endPath(self):
33 if len(self.current.points):
34 self.beziers.append(self.current)
35 self.current = Bezier()
37 def _closePath(self):
38 self.current.close()
39 self._endPath()
41 def _lineTo(self, pt):
42 if len(self.current.points) == 0:
43 self.current.points.append(self._point(self._getCurrentPoint()))
45 self.current.points.append(self._point(pt))
47 def _curveToOne(self, pt1, pt2, pt3):
48 if len(self.current.points) == 0:
49 cp = self._point(self._getCurrentPoint())
50 self.current.points.append(
51 BezierPoint(
52 cp,
53 None,
54 self._point(pt1) - cp
55 )
57 )
58 else:
59 self.current.points[-1].out_tangent = self._point(pt1) - self.current.points[-1].vertex
61 dest = self._point(pt3)
62 self.current.points.append(
63 BezierPoint(
64 dest,
65 self._point(pt2) - dest,
66 None,
67 )
68 )
71class SystemFont:
72 def __init__(self, family):
73 self.family = family
74 self.files = {}
75 self.styles = set()
76 self._renderers = {}
78 def add_file(self, styles, file):
79 self.styles |= set(styles)
80 key = self._key(styles)
81 self.files.setdefault(key, file)
83 def filename(self, styles):
84 return self.files[self._key(styles)]
86 def _key(self, styles):
87 if isinstance(styles, str):
88 return (styles,)
89 return tuple(sorted(styles))
91 def __getitem__(self, styles):
92 key = self._key(styles)
93 if key in self._renderers:
94 return self._renderers[key]
95 fr = RawFontRenderer(self.files[key])
96 self._renderers[key] = fr
97 return fr
99 def __repr__(self):
100 return "<SystemFont %s>" % self.family
103class FontQuery:
104 """!
105 @see https://www.freedesktop.org/software/fontconfig/fontconfig-user.html#AEN21
106 https://manpages.ubuntu.com/manpages/cosmic/man1/fc-pattern.1.html
107 """
108 def __init__(self, str=""):
109 self._query = {}
110 if isinstance(str, FontQuery):
111 self._query = str._query.copy()
112 elif str:
113 chunks = str.split(":")
114 family = chunks.pop(0)
115 self._query = dict(
116 chunk.split("=")
117 for chunk in chunks
118 if chunk
119 )
120 self.family(family)
122 def family(self, name):
123 self._query["family"] = name
124 return self
126 def weight(self, weight):
127 self._query["weight"] = weight
128 return self
130 def css_weight(self, weight):
131 """!
132 Weight from CSS weight value.
134 Weight is different between CSS and fontconfig
135 This creates some interpolations to ensure known values are translated properly
136 @see https://www.freedesktop.org/software/fontconfig/fontconfig-user.html#AEN178
137 https://developer.mozilla.org/en-US/docs/Web/CSS/font-weight#Common_weight_name_mapping
138 """
139 if weight < 200:
140 v = max(0, weight - 100) / 100 * 40
141 elif weight < 500:
142 v = -weight**3 / 200000 + weight**2 * 11/2000 - weight * 17/10 + 200
143 elif weight < 700:
144 v = -weight**2 * 3/1000 + weight * 41/10 - 1200
145 else:
146 v = (weight - 700) / 200 * 10 + 200
147 return self.weight(int(round(v)))
149 def style(self, *styles):
150 self._query["style"] = " ".join(styles)
151 return self
153 def charset(self, *hex_ranges):
154 self._query["charset"] = " ".join(hex_ranges)
155 return self
157 def char(self, char):
158 return self.charset("%x" % ord(char))
160 def custom(self, property, value):
161 self._query[property] = value
162 return self
164 def clone(self):
165 return FontQuery(self)
167 def __getitem__(self, key):
168 return self._query.get(key, "")
170 def __contains__(self, item):
171 return item in self._query
173 def get(self, key, default=None):
174 return self._query.get(key, default)
176 def __str__(self):
177 return self._query.get("family", "") + ":" + ":".join(
178 "%s=%s" % (p, v)
179 for p, v in self._query.items()
180 if p != "family"
181 )
183 def __repr__(self):
184 return "<FontQuery %r>" % str(self)
186 def weight_to_css(self):
187 x = int(self["weight"])
188 if x < 40:
189 v = x / 40 * 100 + 100
190 elif x < 100:
191 v = x**3/300 - x**2 * 11/15 + x*167/3 - 3200/3
192 elif x < 200:
193 v = (2050 - 10 * math.sqrt(5) * math.sqrt(1205 - 6 * x)) / 3
194 else:
195 v = (x - 200) * 200 / 10 + 700
196 return int(round(v))
199class _SystemFontList:
200 def __init__(self):
201 self.fonts = None
203 def _lazy_load(self):
204 if self.fonts is None:
205 self.load()
207 def load(self):
208 self.fonts = {}
209 self.load_fc_list()
211 def cmd(self, *a):
212 p = subprocess.Popen(a, stdout=subprocess.PIPE)
213 out, err = p.communicate()
214 out = out.decode("utf-8").strip()
215 return out, p.returncode
217 def load_fc_list(self):
218 out, returncode = self.cmd("fc-list", r'--format=%{file}\t%{family[0]}\t%{style[0]}\n')
219 if returncode == 0:
220 for line in out.splitlines():
221 file, family, styles = line.split("\t")
222 self._get(family).add_file(styles.split(" "), file)
224 def best(self, query):
225 """!
226 Returns the renderer best matching the name
227 """
228 out, returncode = self.cmd("fc-match", r"--format=%{family}\t%{style}", str(query))
229 if returncode == 0:
230 return self._font_from_match(out)
232 def _font_from_match(self, out):
233 fam, style = out.split("\t")
234 fam = fam.split(",")[0]
235 style = style.split(",")[0].split()
236 return self[fam][style]
238 def all(self, query):
239 """!
240 Yields all the renderers matching a query
241 """
242 out, returncode = self.cmd("fc-match", "-s", r"--format=%{family}\t%{style}\n", str(query))
243 if returncode == 0:
244 for line in out.splitlines():
245 try:
246 yield self._font_from_match(line)
247 except (fontTools.ttLib.TTLibError, fontTools.t1Lib.T1Error):
248 pass
250 def default(self):
251 """!
252 Returns the default fornt renderer
253 """
254 return self.best()
256 def _get(self, family):
257 self._lazy_load()
258 if family in self.fonts:
259 return self.fonts[family]
260 font = SystemFont(family)
261 self.fonts[family] = font
262 return font
264 def __getitem__(self, key):
265 self._lazy_load()
266 return self.fonts[key]
268 def __iter__(self):
269 self._lazy_load()
270 return iter(self.fonts.values())
272 def keys(self):
273 self._lazy_load()
274 return self.fonts.keys()
276 def __contains__(self, item):
277 self._lazy_load()
278 return item in self.fonts
281## Dictionary of system fonts
282fonts = _SystemFontList()
285def collect_kerning_pairs(font):
286 if "GPOS" not in font:
287 return {}
289 gpos_table = font["GPOS"].table
291 unique_kern_lookups = set()
292 for item in gpos_table.FeatureList.FeatureRecord:
293 if item.FeatureTag == "kern":
294 feature = item.Feature
295 unique_kern_lookups |= set(feature.LookupListIndex)
297 kerning_pairs = {}
298 for kern_lookup_index in sorted(unique_kern_lookups):
299 lookup = gpos_table.LookupList.Lookup[kern_lookup_index]
300 if lookup.LookupType in {2, 9}:
301 for pairPos in lookup.SubTable:
302 if pairPos.LookupType == 9: # extension table
303 if pairPos.ExtensionLookupType == 8: # contextual
304 continue
305 elif pairPos.ExtensionLookupType == 2:
306 pairPos = pairPos.ExtSubTable
308 if pairPos.Format != 1:
309 continue
311 firstGlyphsList = pairPos.Coverage.glyphs
312 for ps_index, _ in enumerate(pairPos.PairSet):
313 for pairValueRecordItem in pairPos.PairSet[ps_index].PairValueRecord:
314 secondGlyph = pairValueRecordItem.SecondGlyph
315 valueFormat = pairPos.ValueFormat1
317 if valueFormat == 5: # RTL kerning
318 kernValue = "<%d 0 %d 0>" % (
319 pairValueRecordItem.Value1.XPlacement,
320 pairValueRecordItem.Value1.XAdvance)
321 elif valueFormat == 0: # RTL pair with value <0 0 0 0>
322 kernValue = "<0 0 0 0>"
323 elif valueFormat == 4: # LTR kerning
324 kernValue = pairValueRecordItem.Value1.XAdvance
325 else:
326 print(
327 "\tValueFormat1 = %d" % valueFormat,
328 file=sys.stdout)
329 continue # skip the rest
331 kerning_pairs[(firstGlyphsList[ps_index], secondGlyph)] = kernValue
332 return kerning_pairs
335class GlyphMetrics:
336 def __init__(self, glyph, lsb, aw, xmin, xmax):
337 self.glyph = glyph
338 self.lsb = lsb
339 self.advance = aw
340 self.xmin = xmin
341 self.xmax = xmax
342 self.width = xmax - xmin
343 self.advance = xmax
345 def draw(self, pen):
346 return self.glyph.draw(pen)
349class Font:
350 def __init__(self, wrapped):
351 self.wrapped = wrapped
352 if isinstance(self.wrapped, fontTools.ttLib.TTFont):
353 self.cmap = self.wrapped.getBestCmap() or {}
354 else:
355 self.cmap = {}
357 self.glyphset = self.wrapped.getGlyphSet()
359 @classmethod
360 def open(cls, filename):
361 try:
362 f = fontTools.ttLib.TTFont(filename)
363 except fontTools.ttLib.TTLibError:
364 f = fontTools.t1Lib.T1Font(filename)
365 f.parse()
367 return cls(f)
369 def getGlyphSet(self):
370 return self.wrapped.getGlyphSet()
372 def getBestCmap(self):
373 return {}
375 def glyph_name(self, codepoint):
376 if isinstance(codepoint, str):
377 if len(codepoint) != 1:
378 return ""
379 codepoint = ord(codepoint)
381 if codepoint in self.cmap:
382 return self.cmap[codepoint]
384 return self.calculated_glyph_name(codepoint)
386 @staticmethod
387 def calculated_glyph_name(codepoint):
388 from fontTools import agl # Adobe Glyph List
389 if codepoint in agl.UV2AGL:
390 return agl.UV2AGL[codepoint]
391 elif codepoint <= 0xFFFF:
392 return "uni%04X" % codepoint
393 else:
394 return "u%X" % codepoint
396 def scale(self):
397 if isinstance(self.wrapped, fontTools.ttLib.TTFont):
398 return 1 / self.wrapped["head"].unitsPerEm
399 elif isinstance(self.wrapped, fontTools.t1Lib.T1Font):
400 return self.wrapped["FontMatrix"][0]
402 def yMax(self):
403 if isinstance(self.wrapped, fontTools.ttLib.TTFont):
404 return self.wrapped["head"].yMax
405 elif isinstance(self.wrapped, fontTools.t1Lib.T1Font):
406 return self.wrapped["FontBBox"][3]
408 def glyph(self, glyph_name):
409 if isinstance(self.wrapped, fontTools.ttLib.TTFont):
410 glyph = self.glyphset[glyph_name]
411 table = self.glyphset.glyfTable[glyph_name]
412 xmin = getattr(table, "xMin", glyph.lsb)
413 xmax = getattr(table, "xMax", glyph.width)
414 return GlyphMetrics(glyph, glyph.lsb, glyph.width, xmin, xmax)
415 elif isinstance(self.wrapped, fontTools.t1Lib.T1Font):
416 glyph = self.glyphset[glyph_name]
417 bounds_pen = ControlBoundsPen(self.glyphset)
418 bounds = bounds_pen.bounds
419 glyph.draw(bounds_pen)
420 if not hasattr(glyph, "width"):
421 advance = bounds[2]
422 else:
423 advance = glyph.width
424 return GlyphMetrics(glyph, bounds[0], advance, bounds[0], bounds[2])
426 def __contains__(self, key):
427 if isinstance(self.wrapped, fontTools.t1Lib.T1Font):
428 return key in self.wrapped.font
429 return key in self.wrapped
431 def __getitem__(self, key):
432 return self.wrapped[key]
435class FontRenderer:
436 tab_width = 4
438 @property
439 def font(self):
440 raise NotImplementedError
442 def get_query(self):
443 raise NotImplementedError
445 def kerning(self, c1, c2):
446 return 0
448 def text_to_chars(self, text):
449 return text
451 def _on_missing(self, char, size, pos, group):
452 """!
453 - Character as string
454 - Font size
455 - [in, out] Character position
456 - Group shape
457 """
459 def glyph_name(self, ch):
460 return self.font.glyph_name(ch)
462 def scale(self, size):
463 return size * self.font.scale()
465 def line_height(self, size):
466 return self.font.yMax() * self.scale(size)
468 def ex(self, size):
469 return self.font.glyph("x").advance * self.scale(size)
471 def glyph_beziers(self, glyph, offset=NVector(0, 0)):
472 pen = BezierPen(self.font.glyphset, offset)
473 glyph.draw(pen)
474 return pen.beziers
476 def glyph_shapes(self, glyph, offset=NVector(0, 0)):
477 beziers = self.glyph_beziers(glyph, offset)
478 return [
479 Path(bez)
480 for bez in beziers
481 ]
483 def _on_character(self, ch, size, pos, scale, line, use_kerning, chars, i):
484 chname = self.glyph_name(ch)
486 if chname in self.font.glyphset:
487 glyphdata = self.font.glyph(chname)
488 #pos.x += glyphdata.lsb * scale
489 glyph_shapes = self.glyph_shapes(glyphdata, pos / scale)
491 if glyph_shapes:
492 if len(glyph_shapes) > 1:
493 glyph_shape_group = line.add_shape(Group())
494 glyph_shape = glyph_shape_group
495 else:
496 glyph_shape_group = line
497 glyph_shape = glyph_shapes[0]
499 for sh in glyph_shapes:
500 sh.shape.value.scale(scale)
501 glyph_shape_group.add_shape(sh)
503 glyph_shape.name = ch
505 kerning = 0
506 if use_kerning and i < len(chars) - 1:
507 nextcname = chars[i+1]
508 kerning = self.kerning(chname, nextcname)
510 pos.x += (glyphdata.advance + kerning) * scale
511 return True
512 return False
514 def render(self, text, size, pos=None, use_kerning=True, start_x=None):
515 """!
516 Renders some text
518 @param text String to render
519 @param size Font size (in pizels)
520 @param[in,out] pos Text position
521 @param use_kerning Whether to honour kerning info from the font file
522 @param start_x x-position of the start of a line
524 @returns a Group shape, augmented with some extra attributes:
525 - line_height Line height
526 - next_x X position of the next character
527 """
528 scale = self.scale(size)
529 line_height = self.line_height(size)
530 group = Group()
531 group.name = text
532 if pos is None:
533 pos = NVector(0, 0)
534 start_x = pos.x if start_x is None else start_x
535 line = Group()
536 group.add_shape(line)
537 #group.transform.scale.value = NVector(100, 100) * scale
539 chars = self.text_to_chars(text)
540 for i, ch in enumerate(chars):
541 if ch == "\n":
542 line.next_x = pos.x
543 pos.x = start_x
544 pos.y += line_height
545 line = Group()
546 group.add_shape(line)
547 continue
548 elif ch == "\t":
549 chname = self.glyph_name(ch)
550 if chname in self.font.glyphset:
551 width = self.font.glyph(chname).advance
552 else:
553 width = self.ex(size)
554 pos.x += width * scale * self.tab_width
555 continue
557 self._on_character(ch, size, pos, scale, line, use_kerning, chars, i)
559 group.line_height = line_height
560 group.next_x = line.next_x = pos.x
561 return group
564class RawFontRenderer(FontRenderer):
565 def __init__(self, filename):
566 self.filename = filename
567 self._font = Font.open(filename)
568 self._kerning = None
570 @property
571 def font(self):
572 return self._font
574 def kerning(self, c1, c2):
575 if self._kerning is None:
576 self._kerning = collect_kerning_pairs(self.font)
577 return self._kerning.get((c1, c2), 0)
579 def __repr__(self):
580 return "<FontRenderer %r>" % self.filename
582 def get_query(self):
583 return self.filename
586class FallbackFontRenderer(FontRenderer):
587 def __init__(self, query, max_attempts=10):
588 self.query = FontQuery(query)
589 self._best = None
590 self._bq = None
591 self._fallback = {}
592 self.max_attempts = max_attempts
594 @property
595 def font(self):
596 return self.best.font
598 def get_query(self):
599 return self.query
601 def ex(self, size):
602 best = self.best
603 if "x" not in self.font.glyphset:
604 best = fonts.best(self.query.clone().char("x"))
605 return best.ex(size)
607 @property
608 def best(self):
609 cq = str(self.query)
610 if self._best is None or self._bq != cq:
611 self._best = fonts.best(self.query)
612 self._bq = cq
613 return self._best
615 def fallback_renderer(self, char):
616 if char in self._fallback:
617 return self._fallback[char]
619 if len(char) != 1:
620 return None
622 codepoint = ord(char)
623 name = Font.calculated_glyph_name(codepoint)
624 for i, font in enumerate(fonts.all(self.query.clone().char(char))):
625 # For some reason fontconfig sometimes returns a font that doesn't
626 # actually contain the glyph
627 if name in font.font.glyphset or codepoint in font.font.cmap:
628 self._fallback[char] = font
629 return font
631 if i > self.max_attempts:
632 self._fallback[char] = None
633 return None
635 def _on_character(self, char, size, pos, scale, group, use_kerning, chars, i):
636 if self.best._on_character(char, size, pos, scale, group, use_kerning, chars, i):
637 return True
639 font = self.fallback_renderer(char)
640 if not font:
641 return False
643 child = font.render(char, size, pos)
644 if len(child.shapes) == 2:
645 group.add_shape(child.shapes[0])
646 else:
647 group.add_shape(child)
649 def __repr__(self):
650 return "<FallbackFontRenderer %s>" % self.query
653class EmojiRenderer(FontRenderer):
654 _split = None
656 def __init__(self, wrapped, emoji_dir):
657 if not os.path.isdir(emoji_dir):
658 raise Exception("Not a valid directory: %s" % emoji_dir)
659 self.wrapped = wrapped
660 self.emoji_dir = emoji_dir
661 self._svgs = {}
663 @property
664 def font(self):
665 return self.wrapped.font
667 def emoji_basename(self, char):
668 return "-".join("%x" % ord(cp) for cp in char)
670 def emoji_filename(self, char):
671 return self._get_svg_filename(char)[1]
673 def _get_svg_filename(self, char):
674 basename = self.emoji_basename(char)
675 suffix = ".svg"
677 filename = os.path.join(self.emoji_dir, basename + suffix)
678 if os.path.isfile(filename):
679 return basename, filename
681 filename = os.path.join(self.emoji_dir, basename.upper() + suffix)
682 if os.path.isfile(filename):
683 return basename, filename
685 if char and char[-1] == '\ufe0f':
686 return self._get_svg_filename(char[:-1])
688 return None, None
690 def _get_svg(self, char):
691 from ..parsers.svg import parse_svg_file
693 if char in self._svgs:
694 return self._svgs[char]
696 basename, filename = self._get_svg_filename(char)
697 if filename is None:
698 self._svgs[char] = None
699 return None
701 svga = parse_svg_file(filename)
702 svgshape = Group()
703 svgshape.name = basename
704 for layer in svga.layers:
705 if isinstance(layer, ShapeLayer):
706 for shape in layer.shapes:
707 svgshape.add_shape(shape)
709 self._svgs[char] = svgshape
710 svgshape._bbox = svgshape.bounding_box()
711 return svgshape
713 def _on_character(self, char, size, pos, scale, group, use_kerning, chars, i):
714 svgshape = self._get_svg(char)
715 if svgshape:
716 target_height = self.line_height(size)
717 scale = target_height / svgshape._bbox.height
718 shape_group = Group()
719 shape_group = svgshape.clone()
720 shape_group.transform.scale.value *= scale
721 offset = NVector(
722 -svgshape._bbox.x1 + svgshape._bbox.width * 0.075,
723 -svgshape._bbox.y2 + svgshape._bbox.height * 0.1
724 )
725 shape_group.transform.position.value = pos + offset * scale
726 group.add_shape(shape_group)
727 pos.x += svgshape._bbox.width * scale
728 return True
729 return self.wrapped._on_character(char, size, pos, scale, group, use_kerning, chars, i)
731 def get_query(self):
732 return self.wrapped.get_query()
734 @staticmethod
735 def _get_splitter():
736 if EmojiRenderer._split is None:
737 try:
738 import grapheme
739 EmojiRenderer._split = grapheme.graphemes
740 except ImportError:
741 sys.stderr.write("Install `grapheme` for better Emoji support\n")
742 EmojiRenderer._split = lambda x: x
743 return EmojiRenderer._split
745 @staticmethod
746 def emoji_split(string):
747 return EmojiRenderer._get_splitter()(string)
749 def text_to_chars(self, string):
750 return list(self.emoji_split(string))
753class FontStyle:
754 def __init__(self, query, size, justify=TextJustify.Left, position=None, use_kerning=True, emoji_svg=None):
755 self.emoji_svg = emoji_svg
756 self._set_query(query)
757 self.size = size
758 self.justify = justify
759 self.position = position.clone() if position else NVector(0, 0)
760 self.use_kerning = use_kerning
762 def _set_query(self, query):
763 if isinstance(query, str) and os.path.isfile(query):
764 self._renderer = RawFontRenderer(query)
765 else:
766 self._renderer = FallbackFontRenderer(query)
768 if self.emoji_svg:
769 self._renderer = EmojiRenderer(self._renderer, self.emoji_svg)
771 @property
772 def query(self):
773 return self._renderer.get_query()
775 @query.setter
776 def query(self, value):
777 if str(value) != str(self.query):
778 self._set_query(value)
780 @property
781 def renderer(self):
782 return self._renderer
784 def render(self, text, pos=NVector(0, 0)):
785 group = self._renderer.render(text, self.size, self.position+pos, self.use_kerning)
786 for subg in group.shapes[:-1]:
787 width = subg.next_x - self.position.x - pos.x
788 if self.justify == TextJustify.Center:
789 subg.transform.position.value.x -= width / 2
790 elif self.justify == TextJustify.Right:
791 subg.transform.position.value.x -= width
792 return group
794 def clone(self):
795 return FontStyle(str(self.query), self.size, self.justify, NVector(*self.position), self.use_kerning)
797 @property
798 def ex(self):
799 return self._renderer.ex(self.size)
801 @property
802 def line_height(self):
803 return self._renderer.line_height(self.size)
806def _propfac(a):
807 return property(lambda s: s._get(a), lambda s, v: s._set(a, v)) 807 ↛ exitline 807 didn't run the lambda on line 807 or line 807 didn't run the lambda on line 807
810class FontShape(CustomObject):
811 _props = [
812 LottieProp("query_string", "_query", str),
813 LottieProp("size", "_size", float),
814 LottieProp("justify", "_justify", TextJustify),
815 LottieProp("text", "_text", str),
816 LottieProp("position", "_position", NVector),
817 ]
818 wrapped_lottie = Group
820 def __init__(self, text="", query="", size=64, justify=TextJustify.Left):
821 CustomObject.__init__(self)
822 if isinstance(query, FontStyle):
823 self.style = query
824 else:
825 self.style = FontStyle(query, size, justify)
826 self.text = text
827 self.hidden = None
829 def _get(self, a):
830 return getattr(self.style, a)
832 def _set(self, a, v):
833 return setattr(self.style, a, v)
835 query = _propfac("query")
836 size = _propfac("size")
837 justify = _propfac("justify")
838 position = _propfac("position")
840 @property
841 def query_string(self):
842 return str(self.query)
844 @query_string.setter
845 def query_string(self, v):
846 self.query = v
848 def _build_wrapped(self):
849 g = self.style.render(self.text)
850 self.line_height = g.line_height
851 return g
853 def bounding_box(self, time=0):
854 return self.wrapped.bounding_box(time)