From 9e563ae3b47ded2f6211929989e7e45eba3b46db Mon Sep 17 00:00:00 2001 From: Michael W <50232075+YishiMichael@users.noreply.github.com> Date: Wed, 15 Sep 2021 15:55:19 +0800 Subject: [PATCH] Add Code mobject and rewrite Text.text2settings() --- manimlib/mobject/svg/text_mobject.py | 1125 ++++++++++++++------------ 1 file changed, 600 insertions(+), 525 deletions(-) diff --git a/manimlib/mobject/svg/text_mobject.py b/manimlib/mobject/svg/text_mobject.py index 1719bdae..ad40f9a8 100644 --- a/manimlib/mobject/svg/text_mobject.py +++ b/manimlib/mobject/svg/text_mobject.py @@ -1,525 +1,600 @@ -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 - -import manimpango -from manimlib.constants import * -from manimlib.mobject.geometry import Dot -from manimlib.mobject.svg.svg_mobject import SVGMobject -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, TextSetting, MarkupUtils - -TEXT_MOB_SCALE_FACTOR = 0.0076 -DEFAULT_LINE_SPACING_SCALE = 0.3 - -class Text(SVGMobject): - CONFIG = { - # Mobject - "color": WHITE, - "height": None, - "stroke_width": 0, - # Text - "font": '', - "gradient": None, - "lsh": -1, - "size": None, - "font_size": 48, - "tab_width": 4, - "slant": NORMAL, - "weight": NORMAL, - "t2c": {}, - "t2f": {}, - "t2g": {}, - "t2s": {}, - "t2w": {}, - "disable_ligatures": True, - } - - def __init__(self, text, **config): - self.full2short(config) - digest_config(self, config) - if self.size: - warnings.warn( - "self.size has been deprecated and will " - "be removed in future.", - DeprecationWarning - ) - self.font_size = self.size - if self.lsh == -1: - self.lsh = self.font_size + self.font_size * DEFAULT_LINE_SPACING_SCALE - else: - self.lsh = self.font_size + self.font_size * self.lsh - text_without_tabs = text - if text.find('\t') != -1: - text_without_tabs = text.replace('\t', ' ' * self.tab_width) - self.text = text_without_tabs - file_name = self.text2svg() - PangoUtils.remove_last_M(file_name) - self.remove_empty_path(file_name) - SVGMobject.__init__(self, file_name, **config) - self.text = text - if self.disable_ligatures: - self.apply_space_chars() - if self.t2c: - self.set_color_by_t2c() - if self.gradient: - self.set_color_by_gradient(*self.gradient) - if self.t2g: - self.set_color_by_t2g() - - # anti-aliasing - if self.height is None: - self.scale(TEXT_MOB_SCALE_FACTOR) - - def remove_empty_path(self, file_name): - with open(file_name, 'r') as fpr: - content = fpr.read() - content = re.sub(r'', '', content) - with open(file_name, 'w') as fpw: - fpw.write(content) - - def apply_space_chars(self): - submobs = self.submobjects.copy() - for char_index in range(len(self.text)): - if self.text[char_index] in [" ", "\t", "\n"]: - space = Dot(radius=0, fill_opacity=0, stroke_opacity=0) - space.move_to(submobs[max(char_index - 1, 0)].get_center()) - submobs.insert(char_index, space) - self.set_submobjects(submobs) - - def find_indexes(self, word): - m = re.match(r'\[([0-9\-]{0,}):([0-9\-]{0,})\]', word) - if m: - start = int(m.group(1)) if m.group(1) != '' else 0 - end = int(m.group(2)) if m.group(2) != '' else len(self.text) - start = len(self.text) + start if start < 0 else start - end = len(self.text) + end if end < 0 else end - return [(start, end)] - - indexes = [] - index = self.text.find(word) - while index != -1: - indexes.append((index, index + len(word))) - index = self.text.find(word, index + len(word)) - return indexes - - def get_parts_by_text(self, word): - return VGroup(*( - self[i:j] - for i, j in self.find_indexes(word) - )) - - def get_part_by_text(self, word): - parts = self.get_parts_by_text(word) - if len(parts) > 0: - return parts[0] - else: - return None - - def full2short(self, config): - for kwargs in [config, self.CONFIG]: - if kwargs.__contains__('line_spacing_height'): - kwargs['lsh'] = kwargs.pop('line_spacing_height') - if kwargs.__contains__('text2color'): - kwargs['t2c'] = kwargs.pop('text2color') - if kwargs.__contains__('text2font'): - kwargs['t2f'] = kwargs.pop('text2font') - if kwargs.__contains__('text2gradient'): - kwargs['t2g'] = kwargs.pop('text2gradient') - if kwargs.__contains__('text2slant'): - kwargs['t2s'] = kwargs.pop('text2slant') - if kwargs.__contains__('text2weight'): - kwargs['t2w'] = kwargs.pop('text2weight') - - def set_color_by_t2c(self, t2c=None): - t2c = t2c if t2c else self.t2c - for word, color in list(t2c.items()): - for start, end in self.find_indexes(word): - self[start:end].set_color(color) - - def set_color_by_t2g(self, t2g=None): - t2g = t2g if t2g else self.t2g - for word, gradient in list(t2g.items()): - for start, end in self.find_indexes(word): - self[start:end].set_color_by_gradient(*gradient) - - def text2hash(self): - settings = self.font + self.slant + self.weight - settings += str(self.t2f) + str(self.t2s) + str(self.t2w) - settings += str(self.lsh) + str(self.font_size) - id_str = self.text + settings - hasher = hashlib.sha256() - hasher.update(id_str.encode()) - return hasher.hexdigest()[:16] - - def text2settings(self): - settings = [] - t2x = [self.t2f, self.t2s, self.t2w] - for i in range(len(t2x)): - fsw = [self.font, self.slant, self.weight] - if t2x[i]: - for word, x in list(t2x[i].items()): - for start, end in self.find_indexes(word): - fsw[i] = x - settings.append(TextSetting(start, end, *fsw)) - - # Set All text settings(default font slant weight) - fsw = [self.font, self.slant, self.weight] - settings.sort(key=lambda setting: setting.start) - temp_settings = settings.copy() - start = 0 - for setting in settings: - if setting.start != start: - temp_settings.append(TextSetting(start, setting.start, *fsw)) - start = setting.end - if start != len(self.text): - temp_settings.append(TextSetting(start, len(self.text), *fsw)) - settings = sorted(temp_settings, key=lambda setting: setting.start) - - if re.search(r'\n', self.text): - line_num = 0 - for start, end in self.find_indexes('\n'): - for setting in settings: - if setting.line_num == -1: - setting.line_num = line_num - if start < setting.end: - line_num += 1 - new_setting = copy.copy(setting) - setting.end = end - new_setting.start = end - new_setting.line_num = line_num - settings.append(new_setting) - settings.sort(key=lambda setting: setting.start) - break - - for setting in settings: - if setting.line_num == -1: - setting.line_num = 0 - - return settings - - def text2svg(self): - # anti-aliasing - size = self.font_size - lsh = self.lsh - - if self.font == '': - self.font = get_customization()['style']['font'] - - dir_name = get_text_dir() - hash_name = self.text2hash() - file_name = os.path.join(dir_name, hash_name) + '.svg' - if os.path.exists(file_name): - return file_name - settings = self.text2settings() - width = DEFAULT_PIXEL_WIDTH - height = DEFAULT_PIXEL_HEIGHT - disable_liga = self.disable_ligatures - return manimpango.text2svg( - settings, - size, - lsh, - disable_liga, - file_name, - START_X, - START_Y, - width, - height, - self.text, - ) - - -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. - This searches for the font_file at various places. The order it searches it described below. - 1. Absolute path. - 2. Downloads dir. - - Parameters - ---------- - font_file : - The font file to add. - Examples - -------- - Use ``with register_font(...)`` to add a font file to search - path. - .. code-block:: python - with register_font("path/to/font_file.ttf"): - a = Text("Hello", font="Custom Font Name") - Raises - ------ - FileNotFoundError: - If the font doesn't exists. - AttributeError: - If this method is used on macOS. - Notes - ----- - This method of adding font files also works with :class:`CairoText`. - .. important :: - This method is available for macOS for ``ManimPango>=v0.2.3``. Using this - method with previous releases will raise an :class:`AttributeError` on macOS. - """ - - input_folder = Path(get_downloads_dir()).parent.resolve() - possible_paths = [ - Path(font_file), - input_folder / font_file, - ] - for path in possible_paths: - path = path.resolve() - if path.exists(): - file_path = path - break - else: - error = f"Can't find {font_file}." f"Tried these : {possible_paths}" - raise FileNotFoundError(error) - - try: - assert manimpango.register_font(str(file_path)) - yield - finally: - manimpango.unregister_font(str(file_path)) +import copy +import hashlib +import os +import re +import io +import typing +import warnings +import xml.etree.ElementTree as ET +import functools +import pygments +import pygments.lexers +import pygments.styles + +from contextlib import contextmanager +from pathlib import Path + +import manimpango +from manimlib.constants import * +from manimlib.mobject.geometry import Dot +from manimlib.mobject.svg.svg_mobject import SVGMobject +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, TextSetting, MarkupUtils + +TEXT_MOB_SCALE_FACTOR = 0.0076 +DEFAULT_LINE_SPACING_SCALE = 0.3 + + +class Text(SVGMobject): + CONFIG = { + # Mobject + "color": WHITE, + "height": None, + "stroke_width": 0, + # Text + "font": '', + "gradient": None, + "lsh": -1, + "size": None, + "font_size": 48, + "tab_width": 4, + "slant": NORMAL, + "weight": NORMAL, + "t2c": {}, + "t2f": {}, + "t2g": {}, + "t2s": {}, + "t2w": {}, + "disable_ligatures": True, + } + + def __init__(self, text, **kwargs): + self.full2short(kwargs) + digest_config(self, kwargs) + if self.size: + warnings.warn( + "self.size has been deprecated and will " + "be removed in future.", + DeprecationWarning + ) + self.font_size = self.size + if self.lsh == -1: + self.lsh = self.font_size + self.font_size * DEFAULT_LINE_SPACING_SCALE + else: + self.lsh = self.font_size + self.font_size * self.lsh + text_without_tabs = text + if text.find('\t') != -1: + text_without_tabs = text.replace('\t', ' ' * self.tab_width) + self.text = text_without_tabs + file_name = self.text2svg() + PangoUtils.remove_last_M(file_name) + self.remove_empty_path(file_name) + SVGMobject.__init__(self, file_name, **kwargs) + self.text = text + if self.disable_ligatures: + self.apply_space_chars() + if self.t2c: + self.set_color_by_t2c() + if self.gradient: + self.set_color_by_gradient(*self.gradient) + if self.t2g: + self.set_color_by_t2g() + + # anti-aliasing + if self.height is None: + self.scale(TEXT_MOB_SCALE_FACTOR) + + def remove_empty_path(self, file_name): + with open(file_name, 'r') as fpr: + content = fpr.read() + content = re.sub(r'', '', content) + with open(file_name, 'w') as fpw: + fpw.write(content) + + def apply_space_chars(self): + submobs = self.submobjects.copy() + for char_index in range(len(self.text)): + if self.text[char_index] in [" ", "\t", "\n"]: + space = Dot(radius=0, fill_opacity=0, stroke_opacity=0) + space.move_to(submobs[max(char_index - 1, 0)].get_center()) + submobs.insert(char_index, space) + self.set_submobjects(submobs) + + def find_indexes(self, word): + m = re.match(r'\[([0-9\-]{0,}):([0-9\-]{0,})\]', word) + if m: + start = int(m.group(1)) if m.group(1) != '' else 0 + end = int(m.group(2)) if m.group(2) != '' else len(self.text) + start = len(self.text) + start if start < 0 else start + end = len(self.text) + end if end < 0 else end + return [(start, end)] + + indexes = [] + index = self.text.find(word) + while index != -1: + indexes.append((index, index + len(word))) + index = self.text.find(word, index + len(word)) + return indexes + + def get_parts_by_text(self, word): + return VGroup(*( + self[i:j] + for i, j in self.find_indexes(word) + )) + + def get_part_by_text(self, word): + parts = self.get_parts_by_text(word) + if len(parts) > 0: + return parts[0] + else: + return None + + def full2short(self, config): + for kwargs in [config, self.CONFIG]: + if kwargs.__contains__('line_spacing_height'): + kwargs['lsh'] = kwargs.pop('line_spacing_height') + if kwargs.__contains__('text2color'): + kwargs['t2c'] = kwargs.pop('text2color') + if kwargs.__contains__('text2font'): + kwargs['t2f'] = kwargs.pop('text2font') + if kwargs.__contains__('text2gradient'): + kwargs['t2g'] = kwargs.pop('text2gradient') + if kwargs.__contains__('text2slant'): + kwargs['t2s'] = kwargs.pop('text2slant') + if kwargs.__contains__('text2weight'): + kwargs['t2w'] = kwargs.pop('text2weight') + + def set_color_by_t2c(self, t2c=None): + t2c = t2c if t2c else self.t2c + for word, color in t2c.items(): + for start, end in self.find_indexes(word): + self[start:end].set_color(color) + + def set_color_by_t2g(self, t2g=None): + t2g = t2g if t2g else self.t2g + for word, gradient in t2g.items(): + for start, end in self.find_indexes(word): + self[start:end].set_color_by_gradient(*gradient) + + def text2hash(self): + settings = self.font + self.slant + self.weight + settings += str(self.t2f) + str(self.t2s) + str(self.t2w) + settings += str(self.lsh) + str(self.font_size) + id_str = self.text + settings + hasher = hashlib.sha256() + hasher.update(id_str.encode()) + return hasher.hexdigest()[:16] + + def text2settings(self): + """ + Substrings specified in t2f, t2s, t2w can occupy each other. + For each category of style, a stack following first-in-last-out is constructed, + and the last value in each stack takes effect. + """ + settings = [] + self.line_num = 0 + def add_text_settings(start, end, style_stacks): + if start == end: + return + breakdown_indices = [start, *[ + i + start + 1 for i, char in enumerate(self.text[start:end]) if char == "\n" + ], end] + style = [stack[-1] for stack in style_stacks] + for atom_start, atom_end in zip(breakdown_indices[:-1], breakdown_indices[1:]): + if atom_start < atom_end: + settings.append(TextSetting(atom_start, atom_end, *style, self.line_num)) + self.line_num += 1 + self.line_num -= 1 + + # Set all the default and specified values. + len_text = len(self.text) + t2x_items = sorted([ + *[ + (0, len_text, t2x_index, value) + for t2x_index, value in enumerate([self.font, self.slant, self.weight]) + ], + *[ + (start, end, t2x_index, value) + for t2x_index, t2x in enumerate([self.t2f, self.t2s, self.t2w]) + for word, value in t2x.items() + for start, end in self.find_indexes(word) + ] + ], key=lambda item: item[0]) + + # Break down ranges and construct settings separately. + active_items = [] + style_stacks = [[] for _ in range(3)] + for item, next_start in zip(t2x_items, [*[item[0] for item in t2x_items[1:]], len_text]): + active_items.append(item) + start, end, t2x_index, value = item + style_stacks[t2x_index].append(value) + halting_items = sorted(filter( + lambda item: item[1] <= next_start, + active_items + ), key=lambda item: item[1]) + atom_start = start + for halting_item in halting_items: + active_items.remove(halting_item) + _, atom_end, t2x_index, _ = halting_item + add_text_settings(atom_start, atom_end, style_stacks) + style_stacks[t2x_index].pop() + atom_start = atom_end + add_text_settings(atom_start, next_start, style_stacks) + + del self.line_num + return settings + + def text2svg(self): + # anti-aliasing + size = self.font_size + lsh = self.lsh + + if self.font == '': + self.font = get_customization()['style']['font'] + + dir_name = get_text_dir() + hash_name = self.text2hash() + file_name = os.path.join(dir_name, hash_name) + '.svg' + if os.path.exists(file_name): + return file_name + settings = self.text2settings() + width = DEFAULT_PIXEL_WIDTH + height = DEFAULT_PIXEL_HEIGHT + disable_liga = self.disable_ligatures + return manimpango.text2svg( + settings, + size, + lsh, + disable_liga, + file_name, + START_X, + START_Y, + width, + height, + self.text, + ) + + +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)})" + + +class Code(Text): + CONFIG = { + "font": "Consolas", + "font_size": 24, + "lsh": 1.0, + "language": "python", + # Visit https://pygments.org/demo/ to have a preview of more styles. + "code_style": "monokai", + # If not None, then each character will cover a space of equal width. + "char_width": None + } + + def __init__(self, code, **kwargs): + self.full2short(kwargs) + digest_config(self, kwargs) + code = code.lstrip("\n") # avoid mismatches of character indices + lexer = pygments.lexers.get_lexer_by_name(self.language) + tokens_generator = pygments.lex(code, lexer) + styles_dict = dict(pygments.styles.get_style_by_name(self.code_style)) + default_color_hex = styles_dict[pygments.token.Text]["color"] + if not default_color_hex: + default_color_hex = self.color[1:] + start_index = 0 + t2c = {} + t2s = {} + t2w = {} + for pair in tokens_generator: + ttype, token = pair + end_index = start_index + len(token) + range_str = f"[{start_index}:{end_index}]" + style_dict = styles_dict[ttype] + t2c[range_str] = "#" + (style_dict["color"] or default_color_hex) + t2s[range_str] = ITALIC if style_dict["italic"] else NORMAL + t2w[range_str] = BOLD if style_dict["bold"] else NORMAL + start_index = end_index + t2c.update(self.t2c) + t2s.update(self.t2s) + t2w.update(self.t2w) + kwargs["t2c"] = t2c + kwargs["t2s"] = t2s + kwargs["t2w"] = t2w + Text.__init__(self, code, **kwargs) + if self.char_width is not None: + self.set_monospace(self.char_width) + + def set_monospace(self, char_width): + current_char_index = 0 + for i, char in enumerate(self.text): + if char == "\n": + current_char_index = 0 + continue + self[i].set_x(current_char_index * char_width) + current_char_index += 1 + self.center() + + +@contextmanager +def register_font(font_file: typing.Union[str, Path]): + """Temporarily add a font file to Pango's search path. + This searches for the font_file at various places. The order it searches it described below. + 1. Absolute path. + 2. Downloads dir. + + Parameters + ---------- + font_file : + The font file to add. + Examples + -------- + Use ``with register_font(...)`` to add a font file to search + path. + .. code-block:: python + with register_font("path/to/font_file.ttf"): + a = Text("Hello", font="Custom Font Name") + Raises + ------ + FileNotFoundError: + If the font doesn't exists. + AttributeError: + If this method is used on macOS. + Notes + ----- + This method of adding font files also works with :class:`CairoText`. + .. important :: + This method is available for macOS for ``ManimPango>=v0.2.3``. Using this + method with previous releases will raise an :class:`AttributeError` on macOS. + """ + + input_folder = Path(get_downloads_dir()).parent.resolve() + possible_paths = [ + Path(font_file), + input_folder / font_file, + ] + for path in possible_paths: + path = path.resolve() + if path.exists(): + file_path = path + break + else: + error = f"Can't find {font_file}." f"Tried these : {possible_paths}" + raise FileNotFoundError(error) + + try: + assert manimpango.register_font(str(file_path)) + yield + finally: + manimpango.unregister_font(str(file_path))