from __future__ import annotations from contextlib import contextmanager import os from pathlib import Path import re import manimpango import pygments import pygments.formatters import pygments.lexers from manimlib.constants import DEFAULT_PIXEL_WIDTH, FRAME_WIDTH from manimlib.constants import NORMAL from manimlib.logger import log from manimlib.mobject.svg.labelled_string import LabelledString from manimlib.utils.config_ops import digest_config from manimlib.utils.customization import get_customization from manimlib.utils.directories import get_downloads_dir from manimlib.utils.directories import get_text_dir from manimlib.utils.tex_file_writing import tex_hash from typing import TYPE_CHECKING if TYPE_CHECKING: from colour import Color from typing import Iterable, Union from manimlib.mobject.types.vectorized_mobject import VGroup ManimColor = Union[str, Color] Span = tuple[int, int] Selector = Union[ str, re.Pattern, tuple[Union[int, None], Union[int, None]], Iterable[Union[ str, re.Pattern, tuple[Union[int, None], Union[int, None]] ]] ] TEXT_MOB_SCALE_FACTOR = 0.0076 DEFAULT_LINE_SPACING_SCALE = 0.6 # Ensure the canvas is large enough to hold all glyphs. DEFAULT_CANVAS_WIDTH = 16384 DEFAULT_CANVAS_HEIGHT = 16384 # See https://docs.gtk.org/Pango/pango_markup.html MARKUP_COLOR_KEYS_DICT = { "foreground": False, "fgcolor": False, "color": False, "background": True, "bgcolor": True, "underline_color": True, "overline_color": True, "strikethrough_color": True, } MARKUP_TAG_CONVERSION_DICT = { "b": {"font_weight": "bold"}, "big": {"font_size": "larger"}, "i": {"font_style": "italic"}, "s": {"strikethrough": "true"}, "sub": {"baseline_shift": "subscript", "font_scale": "subscript"}, "sup": {"baseline_shift": "superscript", "font_scale": "superscript"}, "small": {"font_size": "smaller"}, "tt": {"font_family": "monospace"}, "u": {"underline": "single"}, } XML_ENTITIES = ("<", ">", "&", """, "'") XML_ENTITY_CHARS = "<>&\"'" # Temporary handler class _Alignment: VAL_DICT = { "LEFT": 0, "CENTER": 1, "RIGHT": 2 } def __init__(self, s: str): self.value = _Alignment.VAL_DICT[s.upper()] class MarkupText(LabelledString): CONFIG = { "is_markup": True, "font_size": 48, "lsh": None, "justify": False, "indent": 0, "alignment": "LEFT", "line_width": None, "font": "", "slant": NORMAL, "weight": NORMAL, "gradient": None, "t2c": {}, "t2f": {}, "t2g": {}, "t2s": {}, "t2w": {}, "global_config": {}, "local_configs": {}, # When attempting to slice submobs via `get_part_by_text` thereafter, # it's recommended to explicitly specify them in `isolate` attribute # when initializing. # For backward compatibility "isolate": (re.compile(r"[a-zA-Z]+"), re.compile(r"\S+")), } def __init__(self, text: str, **kwargs): self.full2short(kwargs) digest_config(self, kwargs) if not self.font: self.font = get_customization()["style"]["font"] if self.is_markup: self.validate_markup_string(text) self.text = text super().__init__(text, **kwargs) if self.t2g: log.warning( "Manim currently cannot parse gradient from svg. " "Please set gradient via `set_color_by_gradient`.", ) if self.gradient: self.set_color_by_gradient(*self.gradient) if self.height is None: self.scale(TEXT_MOB_SCALE_FACTOR) @property def hash_seed(self) -> tuple: return ( self.__class__.__name__, self.svg_default, self.path_string_config, self.base_color, self.isolate, self.text, self.is_markup, self.font_size, self.lsh, self.justify, self.indent, self.alignment, self.line_width, self.font, self.slant, self.weight, self.t2c, self.t2f, self.t2s, self.t2w, self.global_config, self.local_configs ) def full2short(self, config: dict) -> None: conversion_dict = { "line_spacing_height": "lsh", "text2color": "t2c", "text2font": "t2f", "text2gradient": "t2g", "text2slant": "t2s", "text2weight": "t2w" } for kwargs in [config, self.CONFIG]: for long_name, short_name in conversion_dict.items(): if long_name in kwargs: kwargs[short_name] = kwargs.pop(long_name) def get_file_path_by_content(self, content: str) -> str: hash_content = str(( content, self.justify, self.indent, self.alignment, self.line_width )) svg_file = os.path.join( get_text_dir(), tex_hash(hash_content) + ".svg" ) if not os.path.exists(svg_file): self.markup_to_svg(content, svg_file) return svg_file def markup_to_svg(self, markup_str: str, file_name: str) -> str: self.validate_markup_string(markup_str) # `manimpango` is under construction, # so the following code is intended to suit its interface alignment = _Alignment(self.alignment) if self.line_width is None: pango_width = -1 else: pango_width = self.line_width / FRAME_WIDTH * DEFAULT_PIXEL_WIDTH return manimpango.MarkupUtils.text2svg( text=markup_str, font="", # Already handled slant="NORMAL", # Already handled weight="NORMAL", # Already handled size=1, # Already handled _=0, # Empty parameter disable_liga=False, file_name=file_name, START_X=0, START_Y=0, width=DEFAULT_CANVAS_WIDTH, height=DEFAULT_CANVAS_HEIGHT, justify=self.justify, indent=self.indent, line_spacing=None, # Already handled alignment=alignment, pango_width=pango_width ) @staticmethod def validate_markup_string(markup_str: str) -> None: validate_error = manimpango.MarkupUtils.validate(markup_str) if not validate_error: return raise ValueError( f"Invalid markup string \"{markup_str}\"\n" f"{validate_error}" ) # Parsing def get_cmd_spans(self) -> tuple[list[Span], list[Span], list[Span]]: if not self.is_markup: return [], [], self.find_spans(r"[<>&\x22']") # See https://gitlab.gnome.org/GNOME/glib/-/blob/main/glib/gmarkup.c string = self.string cmd_spans = [] cmd_pattern = re.compile(r""" &[\s\S]*?; # entity & character reference | # tag |<\?[\s\S]*?\?>|<\?> # instruction ||