From 5986d0e7d22fe8e3bd91b7fc94e8711c72822487 Mon Sep 17 00:00:00 2001 From: Naveen M K Date: Sun, 20 Jun 2021 01:08:17 +0530 Subject: [PATCH] Add MarkupText This would use a Pango specific markup which looks like html. There are some specific implementation here about `` and `` Pango doesn't support `` or ` ` instead it works with `color` attribute and gradient isn't supported. Since, `SVGMobject` doesn't know about parsing colors from SVG image and implmentation of `` and `` is added. Co-authored-by: Philipp Imhof <52650214+PhilippImhof@users.noreply.github.com> Signed-off-by: Naveen M K --- manimlib/mobject/svg/text_mobject.py | 238 ++++++++++++++++++++++++++- 1 file changed, 236 insertions(+), 2 deletions(-) diff --git a/manimlib/mobject/svg/text_mobject.py b/manimlib/mobject/svg/text_mobject.py index ed173272..e234ec04 100644 --- a/manimlib/mobject/svg/text_mobject.py +++ b/manimlib/mobject/svg/text_mobject.py @@ -2,8 +2,12 @@ import copy import hashlib import os import re +import io import typing import warnings +import xml.etree.ElementTree as ET +import functools + from contextlib import contextmanager from pathlib import Path @@ -15,8 +19,7 @@ from manimlib.mobject.types.vectorized_mobject import VGroup from manimlib.utils.config_ops import digest_config from manimlib.utils.customization import get_customization from manimlib.utils.directories import get_downloads_dir, get_text_dir -from manimpango import PangoUtils -from manimpango import TextSetting +from manimpango import PangoUtils, TextSetting, MarkupUtils TEXT_MOB_SCALE_FACTOR = 1/100 DEFAULT_LINE_SPACING_SCALE = 0.3 @@ -238,6 +241,237 @@ class Text(SVGMobject): ) +class MarkupText(SVGMobject): + CONFIG = { + # Mobject + "color": WHITE, + "height": None, + # Text + "font": '', + "font_size": 48, + "lsh": None, + "justify": False, + "slant": NORMAL, + "weight": NORMAL, + "tab_width": 4, + "gradient": None, + "disable_ligatures": True, + } + def __init__(self, text, **config): + digest_config(self, config) + self.text = f'{text}' + self.original_text = self.text + self.text_for_parsing = self.text + text_without_tabs = text + if "\t" in text: + text_without_tabs = text.replace("\t", " " * self.tab_width) + try: + colormap = self.extract_color_tags() + gradientmap = self.extract_gradient_tags() + except ET.ParseError: + # let pango handle that error + pass + validate_error = MarkupUtils.validate(self.text) + if validate_error: + raise ValueError(validate_error) + file_name = self.text2svg() + PangoUtils.remove_last_M(file_name) + super().__init__( + file_name, + **config, + ) + self.chars = self.get_group_class()(*self.submobjects) + self.text = text_without_tabs.replace(" ", "").replace("\n", "") + if self.gradient: + self.set_color_by_gradient(*self.gradient) + for col in colormap: + self.chars[ + col["start"] + - col["start_offset"] : col["end"] + - col["start_offset"] + - col["end_offset"] + ].set_color(self._parse_color(col["color"])) + for grad in gradientmap: + self.chars[ + grad["start"] + - grad["start_offset"] : grad["end"] + - grad["start_offset"] + - grad["end_offset"] + ].set_color_by_gradient( + *(self._parse_color(grad["from"]), self._parse_color(grad["to"])) + ) + # anti-aliasing + if self.height is None: + self.scale(TEXT_MOB_SCALE_FACTOR) + def text2hash(self): + """Generates ``sha256`` hash for file name.""" + settings = ( + "MARKUPPANGO" + self.font + self.slant + self.weight + self.color + ) # to differentiate from classical Pango Text + settings += str(self.lsh) + str(self.font_size) + settings += str(self.disable_ligatures) + settings += str(self.justify) + id_str = self.text + settings + hasher = hashlib.sha256() + hasher.update(id_str.encode()) + return hasher.hexdigest()[:16] + + def text2svg(self): + """Convert the text to SVG using Pango.""" + size = self.font_size + dir_name = get_text_dir() + disable_liga = self.disable_ligatures + if not os.path.exists(dir_name): + os.makedirs(dir_name) + hash_name = self.text2hash() + file_name = os.path.join(dir_name, hash_name) + ".svg" + if os.path.exists(file_name): + return file_name + + extra_kwargs = {} + extra_kwargs['justify'] = self.justify + extra_kwargs['pango_width'] = DEFAULT_PIXEL_WIDTH - 100 + if self.lsh: + extra_kwargs['line_spacing']=self.lsh + return MarkupUtils.text2svg( + f'{self.text}', + self.font, + self.slant, + self.weight, + size, + 0, # empty parameter + disable_liga, + file_name, + START_X, + START_Y, + DEFAULT_PIXEL_WIDTH, # width + DEFAULT_PIXEL_HEIGHT, # height + **extra_kwargs + ) + + + def _parse_color(self, col): + """Parse color given in ```` or ```` tags.""" + if re.match("#[0-9a-f]{6}", col): + return col + else: + return globals()[col.upper()] # this is hacky + + @functools.lru_cache(10) + def get_text_from_markup(self, element=None): + if not element: + element = ET.fromstring(self.text_for_parsing) + final_text = '' + for i in element.itertext(): + final_text += i + return final_text + + def extract_color_tags(self, text=None, colormap = None): + """Used to determine which parts (if any) of the string should be formatted + with a custom color. + Removes the ```` tag, as it is not part of Pango's markup and would cause an error. + Note: Using the ```` tags is deprecated. As soon as the legacy syntax is gone, this function + will be removed. + """ + if not text: + text = self.text_for_parsing + if not colormap: + colormap = list() + elements = ET.fromstring(text) + text_from_markup = self.get_text_from_markup() + final_xml = ET.fromstring(f'{elements.text if elements.text else ""}') + def get_color_map(elements): + for element in elements: + if element.tag == 'color': + element_text = self.get_text_from_markup(element) + start = text_from_markup.find(element_text) + end = start + len(element_text) + offsets = element.get('offset').split(",") if element.get('offset') else [0] + start_offset = int(offsets[0]) if offsets[0] else 0 + end_offset = int(offsets[1]) if len(offsets) == 2 and offsets[1] else 0 + colormap.append( + { + "start": start, + "end": end, + "color": element.get('col'), + "start_offset": start_offset, + "end_offset": end_offset, + } + ) + + _elements_list = list(element.iter()) + if len(_elements_list) <= 1: + final_xml.append(ET.fromstring(f'{element.text if element.text else ""}')) + else: + final_xml.append(_elements_list[-1]) + else: + if len(list(element.iter())) == 1: + final_xml.append(element) + else: + get_color_map(element) + get_color_map(elements) + with io.BytesIO() as f: + tree = ET.ElementTree() + tree._setroot(final_xml) + tree.write(f) + self.text = f.getvalue().decode() + self.text_for_parsing = self.text # gradients will use it + return colormap + + def extract_gradient_tags(self, text=None,gradientmap=None): + """Used to determine which parts (if any) of the string should be formatted + with a gradient. + Removes the ```` tag, as it is not part of Pango's markup and would cause an error. + """ + if not text: + text = self.text_for_parsing + if not gradientmap: + gradientmap = list() + + elements = ET.fromstring(text) + text_from_markup = self.get_text_from_markup() + final_xml = ET.fromstring(f'{elements.text if elements.text else ""}') + def get_gradient_map(elements): + for element in elements: + if element.tag == 'gradient': + element_text = self.get_text_from_markup(element) + start = text_from_markup.find(element_text) + end = start + len(element_text) + offsets = element.get('offset').split(",") if element.get('offset') else [0] + start_offset = int(offsets[0]) if offsets[0] else 0 + end_offset = int(offsets[1]) if len(offsets) == 2 and offsets[1] else 0 + gradientmap.append( + { + "start": start, + "end": end, + "from": element.get('from'), + "to": element.get('to'), + "start_offset": start_offset, + "end_offset": end_offset, + } + ) + _elements_list = list(element.iter()) + if len(_elements_list) == 1: + final_xml.append(ET.fromstring(f'{element.text if element.text else ""}')) + else: + final_xml.append(_elements_list[-1]) + else: + if len(list(element.iter())) == 1: + final_xml.append(element) + else: + get_gradient_map(element) + get_gradient_map(elements) + with io.BytesIO() as f: + tree = ET.ElementTree() + tree._setroot(final_xml) + tree.write(f) + self.text = f.getvalue().decode() + + return gradientmap + + def __repr__(self): + return f"MarkupText({repr(self.original_text)})" + @contextmanager def register_font(font_file: typing.Union[str, Path]): """Temporarily add a font file to Pango's search path.