diff --git a/manim/animation/transform.py b/manim/animation/transform.py index 4f6e11e522..9d694b5c1c 100644 --- a/manim/animation/transform.py +++ b/manim/animation/transform.py @@ -30,6 +30,7 @@ import numpy as np +from .. import config from ..animation.animation import Animation from ..constants import DEFAULT_POINTWISE_FUNCTION_RUN_TIME, DEGREES, OUT from ..mobject.mobject import Group, Mobject @@ -80,7 +81,10 @@ def begin(self) -> None: self.target_copy = self.target_mobject.copy() # Note, this potentially changes the structure # of both mobject and target_mobject - self.mobject.align_data(self.target_copy) + if config["renderer"] == "opengl": + self.mobject.align_data_and_family(self.target_copy) + else: + self.mobject.align_data(self.target_copy) super().begin() def create_target(self) -> typing.Union[Mobject, None]: diff --git a/manim/mobject/mobject.py b/manim/mobject/mobject.py index 021070e1f5..4cff59d5a9 100644 --- a/manim/mobject/mobject.py +++ b/manim/mobject/mobject.py @@ -223,7 +223,10 @@ def __deepcopy__(self, clone_from_id): return result def __repr__(self): - return str(self.name) + if config["renderer"] == "opengl": + return super().__repr__() + else: + return str(self.name) def reset_points(self): """Sets :attr:`points` to be an empty array.""" diff --git a/manim/mobject/opengl_mobject.py b/manim/mobject/opengl_mobject.py index 23df0e452c..ba82c6f6ee 100644 --- a/manim/mobject/opengl_mobject.py +++ b/manim/mobject/opengl_mobject.py @@ -135,7 +135,101 @@ def animate(self): # Borrowed from https://github.com/ManimCommunity/manim/ return _AnimationBuilder(self) + @property + def width(self): + """The width of the mobject. + + Returns + ------- + :class:`float` + + Examples + -------- + .. manim:: WidthExample + + class WidthExample(Scene): + def construct(self): + decimal = DecimalNumber().to_edge(UP) + rect = Rectangle(color=BLUE) + rect_copy = rect.copy().set_stroke(GRAY, opacity=0.5) + + decimal.add_updater(lambda d: d.set_value(rect.width)) + + self.add(rect_copy, rect, decimal) + self.play(rect.animate.set(width=7)) + self.wait() + + See also + -------- + :meth:`length_over_dim` + + """ + + # Get the length across the X dimension + return self.length_over_dim(0) + # Only these methods should directly affect points + @width.setter + def width(self, value): + self.rescale_to_fit(value, 0, stretch=False) + + @property + def height(self): + """The height of the mobject. + + Returns + ------- + :class:`float` + + Examples + -------- + .. manim:: HeightExample + + class HeightExample(Scene): + def construct(self): + decimal = DecimalNumber().to_edge(UP) + rect = Rectangle(color=BLUE) + rect_copy = rect.copy().set_stroke(GRAY, opacity=0.5) + + decimal.add_updater(lambda d: d.set_value(rect.height)) + + self.add(rect_copy, rect, decimal) + self.play(rect.animate.set(height=5)) + self.wait() + + See also + -------- + :meth:`length_over_dim` + + """ + + # Get the length across the Y dimension + return self.length_over_dim(1) + + @height.setter + def height(self, value): + self.rescale_to_fit(value, 1, stretch=False) + + @property + def depth(self): + """The depth of the mobject. + + Returns + ------- + :class:`float` + + See also + -------- + :meth:`length_over_dim` + + """ + + # Get the length across the Z dimension + return self.length_over_dim(2) + + @depth.setter + def depth(self, value): + self.rescale_to_fit(value, 2, stretch=False) def resize_points(self, new_length, resize_func=resize_array): if new_length != len(self.data["points"]): @@ -619,7 +713,6 @@ def rotate( self, angle, axis=OUT, - about_point: Union[np.ndarray, List, None] = None, **kwargs, ): rot_matrix_T = rotation_matrix_transpose(angle, axis) diff --git a/manim/mobject/svg/opengl_svg_mobject.py b/manim/mobject/svg/opengl_svg_mobject.py new file mode 100644 index 0000000000..7e7d0c9442 --- /dev/null +++ b/manim/mobject/svg/opengl_svg_mobject.py @@ -0,0 +1,42 @@ +from ...constants import * +from ..svg.svg_mobject import SVGMobject +from ..types.opengl_vectorized_mobject import OpenGLVMobject +from .opengl_svg_path import OpenGLSVGPathMobject +from .style_utils import cascade_element_style, parse_style + + +class OpenGLSVGMobject(OpenGLVMobject, SVGMobject): + def __init__( + self, + file_name=None, + should_center=True, + height=2, + width=None, + unpack_groups=True, # if False, creates a hierarchy of VGroups + stroke_width=DEFAULT_STROKE_WIDTH, + fill_opacity=1.0, + should_subdivide_sharp_curves=False, + should_remove_null_curves=False, + **kwargs, + ): + self.def_map = {} + self.file_name = file_name or self.file_name + self.ensure_valid_file() + self.should_center = should_center + self.unpack_groups = unpack_groups + self.path_string_config = { + "should_subdivide_sharp_curves": should_subdivide_sharp_curves, + "should_remove_null_curves": should_remove_null_curves, + } + OpenGLVMobject.__init__( + self, stroke_width=stroke_width, fill_opacity=fill_opacity, **kwargs + ) + self.move_into_position(width, height) + + def init_points(self): + self.generate_points() + + def path_string_to_mobject(self, path_string: str, style: dict): + return OpenGLSVGPathMobject( + path_string, **self.path_string_config, **parse_style(style) + ) diff --git a/manim/mobject/svg/opengl_svg_path.py b/manim/mobject/svg/opengl_svg_path.py new file mode 100644 index 0000000000..cd6cf530b8 --- /dev/null +++ b/manim/mobject/svg/opengl_svg_path.py @@ -0,0 +1,29 @@ +import numpy as np + +from ..types.opengl_vectorized_mobject import OpenGLVMobject +from .svg_path import SVGPathMobject + + +class OpenGLSVGPathMobject(OpenGLVMobject, SVGPathMobject): + def __init__( + self, + path_string, + should_subdivide_sharp_curves=False, + should_remove_null_curves=False, + **kwargs + ): + self.path_string = path_string + OpenGLVMobject.__init__( + self, + long_lines=True, + should_subdivide_sharp_curves=should_subdivide_sharp_curves, + should_remove_null_curves=should_remove_null_curves, + **kwargs + ) + self.current_path_start = np.zeros((1, self.dim)) + + def init_points(self): + self.generate_points() + + def start_new_path(self, point): + SVGPathMobject.start_new_path(self, point) diff --git a/manim/mobject/svg/opengl_tex_mobject.py b/manim/mobject/svg/opengl_tex_mobject.py new file mode 100644 index 0000000000..1556ebca4e --- /dev/null +++ b/manim/mobject/svg/opengl_tex_mobject.py @@ -0,0 +1,615 @@ +r"""Mobjects representing text rendered using LaTeX. + + +The Tex mobject ++++++++++++++++ +Just as you can use :class:`~.Text` to add text to your videos, you can use :class:`~.Tex` to insert LaTeX. + +.. manim:: HelloLaTeX + :save_last_frame: + + class HelloLaTeX(Scene): + def construct(self): + tex = Tex(r'\LaTeX').scale(3) + self.add(tex) + +Note that we are using a raw string (``r'---'``) instead of a regular string (``'---'``). +This is because TeX code uses a lot of special characters - like ``\`` for example - +that have special meaning within a regular python string. An alternative would have +been to write ``\\`` as in ``Tex('\\LaTeX')``. + +The MathTex mobject ++++++++++++++++++++ +Anything enclosed in ``$`` signs is interpreted as maths-mode: + +.. manim:: HelloTex + :save_last_frame: + + class HelloTex(Scene): + def construct(self): + tex = Tex(r'$\xrightarrow{x^2y^3}$ \LaTeX').scale(3) + self.add(tex) + +Whereas in a :class:`~.MathTex` mobject everything is math-mode by default. + +.. manim:: MovingBraces + + class MovingBraces(Scene): + def construct(self): + text=MathTex( + "\\frac{d}{dx}f(x)g(x)=", #0 + "f(x)\\frac{d}{dx}g(x)", #1 + "+", #2 + "g(x)\\frac{d}{dx}f(x)" #3 + ) + self.play(Write(text)) + brace1 = Brace(text[1], UP, buff=SMALL_BUFF) + brace2 = Brace(text[3], UP, buff=SMALL_BUFF) + t1 = brace1.get_text("$g'f$") + t2 = brace2.get_text("$f'g$") + self.play( + GrowFromCenter(brace1), + FadeIn(t1), + ) + self.wait() + self.play( + ReplacementTransform(brace1,brace2), + ReplacementTransform(t1,t2) + ) + self.wait() + + +LaTeX commands and keyword arguments +++++++++++++++++++++++++++++++++++++ +We can use any standard LaTeX commands in the AMS maths packages. For example the ``mathtt`` math-text type, or the ``looparrowright`` arrow. + +.. manim:: AMSLaTeX + :save_last_frame: + + class AMSLaTeX(Scene): + def construct(self): + tex = Tex(r'$\mathtt{H} \looparrowright$ \LaTeX').scale(3) + self.add(tex) + +On the manim side, the :class:`~.Tex` class also accepts attributes to change the appearance of the output. +This is very similar to the :class:`~.Text` class. For example, the ``color`` keyword changes the color of the TeX mobject: + +.. manim:: LaTeXAttributes + :save_last_frame: + + class LaTeXAttributes(Scene): + def construct(self): + tex = Tex(r'Hello \LaTeX', color=BLUE).scale(3) + self.add(tex) + +Extra LaTeX Packages +++++++++++++++++++++ +Some commands require special packages to be loaded into the TeX template. For example, +to use the ``mathscr`` script, we need to add the ``mathrsfs`` package. Since this package isn't loaded +into manim's tex template by default, we add it manually: + +.. manim:: AddPackageLatex + :save_last_frame: + + class AddPackageLatex(Scene): + def construct(self): + myTemplate = TexTemplate() + myTemplate.add_to_preamble(r"\usepackage{mathrsfs}") + tex = Tex(r'$\mathscr{H} \rightarrow \mathbb{H}$}', tex_template=myTemplate).scale(3) + self.add(tex) + +Substrings and parts +++++++++++++++++++++ +The TeX mobject can accept multiple strings as arguments. Afterwards you can refer to the individual +parts either by their index (like ``tex[1]``), or you can look them up by (parts of) the tex code like +in this example where we set the color of the ``\bigstar`` using :func:`~.set_color_by_tex`: + +.. manim:: LaTeXSubstrings + :save_last_frame: + + class LaTeXSubstrings(Scene): + def construct(self): + tex = Tex('Hello', r'$\bigstar$', r'\LaTeX').scale(3) + tex.set_color_by_tex('igsta', RED) + self.add(tex) + +LaTeX Maths Fonts - The Template Library +++++++++++++++++++++++++++++++++++++++++ +Changing fonts in LaTeX when typesetting mathematical formulae is a little bit more tricky than +with regular text. It requires changing the template that is used to compile the tex code. +Manim comes with a collection of :class:`~.TexFontTemplates` ready for you to use. These templates will all work +in maths mode: + +.. manim:: LaTeXMathFonts + :save_last_frame: + + class LaTeXMathFonts(Scene): + def construct(self): + tex = Tex(r'$x^2 + y^2 = z^2$', tex_template=TexFontTemplates.french_cursive).scale(3) + self.add(tex) + +Manim also has a :class:`~.TexTemplateLibrary` containing the TeX templates used by 3Blue1Brown. One example +is the ctex template, used for typesetting Chinese. For this to work, the ctex LaTeX package +must be installed on your system. Furthermore, if you are only typesetting Text, you probably do not +need :class:`~.Tex` at all, and should use :class:`~.Text` or :class:`~.PangoText` instead. + +.. manim:: LaTeXTemplateLibrary + :save_last_frame: + + class LaTeXTemplateLibrary(Scene): + def construct(self): + tex = Tex('Hello 你好 \\LaTeX', tex_template=TexTemplateLibrary.ctex).scale(3) + self.add(tex) + + +Aligning formulae ++++++++++++++++++ +A :class:`~.MathTex` mobject is typeset in the LaTeX ``align*`` environment. This means you can use the ``&`` alignment +character when typesetting multiline formulae: + +.. manim:: LaTeXAlignEnvironment + :save_last_frame: + + class LaTeXAlignEnvironment(Scene): + def construct(self): + tex = MathTex(r'f(x) &= 3 + 2 + 1\\ &= 5 + 1 \\ &= 6').scale(2) + self.add(tex) +""" + +__all__ = [ + "OpenGLTexSymbol", + "OpenGLSingleStringMathTex", + "OpenGLMathTex", + "OpenGLTex", + "OpenGLBulletedList", + "OpenGLTitle", + "OpenGLTexMobject", + "OpenGLTextMobject", +] + + +import operator as op +from functools import reduce + +from ... import config, logger +from ...constants import * +from ...mobject.opengl_geometry import OpenGLLine +from ...mobject.svg.opengl_svg_mobject import OpenGLSVGMobject +from ...mobject.svg.opengl_svg_path import OpenGLSVGPathMobject +from ...mobject.types.opengl_vectorized_mobject import ( + OpenGLVectorizedPoint, + OpenGLVGroup, +) +from ...utils.color import BLACK +from ...utils.strings import split_string_list_to_isolate_substrings +from ...utils.tex_file_writing import tex_to_svg_file +from .style_utils import parse_style + +# from ...utils.tex import TexTemplate + +TEX_MOB_SCALE_FACTOR = 0.05 + + +class OpenGLTexSymbol(OpenGLSVGPathMobject): + """Purely a renaming of SVGPathMobject.""" + + pass + + +class OpenGLSingleStringMathTex(OpenGLSVGMobject): + """Elementary building block for rendering text with LaTeX. + + Tests + ----- + Check that creating a :class:`~.SingleStringMathTex` object works:: + + >>> SingleStringMathTex('Test') + SingleStringMathTex('Test') + """ + + def __init__( + self, + tex_string, + stroke_width=0, + fill_opacity=1.0, + background_stroke_width=0, + background_stroke_color=BLACK, + should_center=True, + height=None, + organize_left_to_right=False, + tex_environment="align*", + tex_template=None, + **kwargs, + ): + self.organize_left_to_right = organize_left_to_right + self.tex_environment = tex_environment + if tex_template is None: + tex_template = config["tex_template"] + self.tex_template = tex_template + + assert isinstance(tex_string, str) + self.tex_string = tex_string + file_name = tex_to_svg_file( + self.get_modified_expression(tex_string), + environment=self.tex_environment, + tex_template=self.tex_template, + ) + OpenGLSVGMobject.__init__( + self, + file_name=file_name, + should_center=should_center, + stroke_width=stroke_width, + height=height, + fill_opacity=fill_opacity, + background_stroke_width=background_stroke_width, + background_stroke_color=background_stroke_color, + should_subdivide_sharp_curves=True, + should_remove_null_curves=True, + **kwargs, + ) + if height is None: + self.scale(TEX_MOB_SCALE_FACTOR) + if self.organize_left_to_right: + self.organize_submobjects_left_to_right() + + def __repr__(self): + return f"{type(self).__name__}({repr(self.tex_string)})" + + def get_modified_expression(self, tex_string): + result = tex_string + result = result.strip() + result = self.modify_special_strings(result) + return result + + def modify_special_strings(self, tex): + tex = self.remove_stray_braces(tex) + should_add_filler = reduce( + op.or_, + [ + # Fraction line needs something to be over + tex == "\\over", + tex == "\\overline", + # Make sure sqrt has overbar + tex == "\\sqrt", + # Need to add blank subscript or superscript + tex.endswith("_"), + tex.endswith("^"), + tex.endswith("dot"), + ], + ) + if should_add_filler: + filler = "{\\quad}" + tex += filler + + if tex == "\\substack": + tex = "\\quad" + + if tex == "": + tex = "\\quad" + + # To keep files from starting with a line break + if tex.startswith("\\\\"): + tex = tex.replace("\\\\", "\\quad\\\\") + + # Handle imbalanced \left and \right + num_lefts, num_rights = [ + len([s for s in tex.split(substr)[1:] if s and s[0] in "(){}[]|.\\"]) + for substr in ("\\left", "\\right") + ] + if num_lefts != num_rights: + tex = tex.replace("\\left", "\\big") + tex = tex.replace("\\right", "\\big") + + for context in ["array"]: + begin_in = ("\\begin{%s}" % context) in tex + end_in = ("\\end{%s}" % context) in tex + if begin_in ^ end_in: + # Just turn this into a blank string, + # which means caller should leave a + # stray \\begin{...} with other symbols + tex = "" + return tex + + def remove_stray_braces(self, tex): + r""" + Makes :class:`~.MathTex` resilient to unmatched braces. + + This is important when the braces in the TeX code are spread over + multiple arguments as in, e.g., ``MathTex(r"e^{i", r"\tau} = 1")``. + """ + + # "\{" does not count (it's a brace literal), but "\\{" counts (it's a new line and then brace) + num_lefts = tex.count("{") - tex.count("\\{") + tex.count("\\\\{") + num_rights = tex.count("}") - tex.count("\\}") + tex.count("\\\\}") + while num_rights > num_lefts: + tex = "{" + tex + num_lefts += 1 + while num_lefts > num_rights: + tex = tex + "}" + num_rights += 1 + return tex + + def get_tex_string(self): + return self.tex_string + + def path_string_to_mobject(self, path_string, style): + # Overwrite superclass default to use + # specialized path_string mobject + return OpenGLTexSymbol( + path_string, **self.path_string_config, **parse_style(style) + ) + + def organize_submobjects_left_to_right(self): + self.sort(lambda p: p[0]) + return self + + def init_colors(self, propagate_colors=True): + OpenGLSVGMobject.set_style( + self, + fill_color=self.fill_color or self.color, + fill_opacity=self.fill_opacity, + stroke_color=self.stroke_color or self.color, + stroke_width=self.stroke_width, + stroke_opacity=self.stroke_opacity, + recurse=propagate_colors, + ) + + +class OpenGLMathTex(OpenGLSingleStringMathTex): + r"""A string compiled with LaTeX in math mode. + + Examples + -------- + .. manim:: Formula + :save_last_frame: + + class Formula(Scene): + def construct(self): + t = MathTex(r"\int_a^b f'(x) dx = f(b)- f(a)") + self.add(t) + + Tests + ----- + Check that creating a :class:`~.MathTex` works:: + + >>> MathTex('a^2 + b^2 = c^2') + MathTex('a^2 + b^2 = c^2') + + """ + + def __init__( + self, + *tex_strings, + arg_separator=" ", + substrings_to_isolate=None, + tex_to_color_map=None, + tex_environment="align*", + **kwargs, + ): + self.tex_template = kwargs.pop("tex_template", config["tex_template"]) + self.arg_separator = arg_separator + self.substrings_to_isolate = ( + [] if substrings_to_isolate is None else substrings_to_isolate + ) + self.tex_to_color_map = tex_to_color_map + if self.tex_to_color_map is None: + self.tex_to_color_map = {} + self.tex_environment = tex_environment + tex_strings = self.break_up_tex_strings(tex_strings) + self.tex_strings = tex_strings + OpenGLSingleStringMathTex.__init__( + self, + self.arg_separator.join(tex_strings), + tex_environment=self.tex_environment, + tex_template=self.tex_template, + **kwargs, + ) + self.break_up_by_substrings() + self.set_color_by_tex_to_color_map(self.tex_to_color_map) + + if self.organize_left_to_right: + self.organize_submobjects_left_to_right() + + def break_up_tex_strings(self, tex_strings): + substrings_to_isolate = op.add( + self.substrings_to_isolate, list(self.tex_to_color_map.keys()) + ) + split_list = split_string_list_to_isolate_substrings( + tex_strings, *substrings_to_isolate + ) + if self.arg_separator == " ": + split_list = [str(x).strip() for x in split_list] + # split_list = list(map(str.strip, split_list)) + split_list = [s for s in split_list if s != ""] + return split_list + + def break_up_by_substrings(self): + """ + Reorganize existing submobjects one layer + deeper based on the structure of tex_strings (as a list + of tex_strings) + """ + new_submobjects = [] + curr_index = 0 + for tex_string in self.tex_strings: + sub_tex_mob = OpenGLSingleStringMathTex( + tex_string, + tex_environment=self.tex_environment, + tex_template=self.tex_template, + ) + num_submobs = len(sub_tex_mob.submobjects) + new_index = curr_index + num_submobs + if num_submobs == 0: + # For cases like empty tex_strings, we want the corresponding + # part of the whole MathTex to be a VectorizedPoint + # positioned in the right part of the MathTex + sub_tex_mob.submobjects = [OpenGLVectorizedPoint()] + sub_tex_mob.assemble_family() + last_submob_index = min(curr_index, len(self.submobjects) - 1) + sub_tex_mob.move_to(self.submobjects[last_submob_index], RIGHT) + else: + sub_tex_mob.submobjects = self.submobjects[curr_index:new_index] + sub_tex_mob.assemble_family() + new_submobjects.append(sub_tex_mob) + curr_index = new_index + self.submobjects = new_submobjects + self.assemble_family() + return self + + def get_parts_by_tex(self, tex, substring=True, case_sensitive=True): + def test(tex1, tex2): + if not case_sensitive: + tex1 = tex1.lower() + tex2 = tex2.lower() + if substring: + return tex1 in tex2 + else: + return tex1 == tex2 + + return OpenGLVGroup( + *[m for m in self.submobjects if test(tex, m.get_tex_string())] + ) + + def get_part_by_tex(self, tex, **kwargs): + all_parts = self.get_parts_by_tex(tex, **kwargs) + return all_parts[0] if all_parts else None + + def set_color_by_tex(self, tex, color, **kwargs): + parts_to_color = self.get_parts_by_tex(tex, **kwargs) + for part in parts_to_color: + part.set_color(color) + return self + + def set_color_by_tex_to_color_map(self, texs_to_color_map, **kwargs): + for texs, color in list(texs_to_color_map.items()): + try: + # If the given key behaves like tex_strings + texs + "" + self.set_color_by_tex(texs, color, **kwargs) + except TypeError: + # If the given key is a tuple + for tex in texs: + self.set_color_by_tex(tex, color, **kwargs) + return self + + def index_of_part(self, part): + split_self = self.split() + if part not in split_self: + raise ValueError("Trying to get index of part not in MathTex") + return split_self.index(part) + + def index_of_part_by_tex(self, tex, **kwargs): + part = self.get_part_by_tex(tex, **kwargs) + return self.index_of_part(part) + + def sort_alphabetically(self): + self.submobjects.sort(key=lambda m: m.get_tex_string()) + + +class OpenGLTex(OpenGLMathTex): + r"""A string compiled with LaTeX in normal mode. + + Tests + ----- + + Check whether writing a LaTeX string works:: + + >>> Tex('The horse does not eat cucumber salad.') + Tex('The horse does not eat cucumber salad.') + + """ + + def __init__( + self, *tex_strings, arg_separator="", tex_environment="center", **kwargs + ): + OpenGLMathTex.__init__( + self, + *tex_strings, + arg_separator=arg_separator, + tex_environment=tex_environment, + **kwargs, + ) + + +class OpenGLBulletedList(OpenGLTex): + def __init__( + self, + *items, + buff=MED_LARGE_BUFF, + dot_scale_factor=2, + tex_environment=None, + **kwargs, + ): + self.buff = buff + self.dot_scale_factor = dot_scale_factor + self.tex_environment = tex_environment + line_separated_items = [s + "\\\\" for s in items] + Tex.__init__( + self, *line_separated_items, tex_environment=tex_environment, **kwargs + ) + for part in self: + dot = MathTex("\\cdot").scale(self.dot_scale_factor) + dot.next_to(part[0], LEFT, SMALL_BUFF) + part.add_to_back(dot) + self.arrange(DOWN, aligned_edge=LEFT, buff=self.buff) + + def fade_all_but(self, index_or_string, opacity=0.5): + arg = index_or_string + if isinstance(arg, str): + part = self.get_part_by_tex(arg) + elif isinstance(arg, int): + part = self.submobjects[arg] + else: + raise TypeError(f"Expected int or string, got {arg}") + for other_part in self.submobjects: + if other_part is part: + other_part.set_fill(opacity=1) + else: + other_part.set_fill(opacity=opacity) + + +class OpenGLTitle(OpenGLTex): + def __init__( + self, + *text_parts, + scale_factor=1, + include_underline=True, + match_underline_width_to_text=False, + underline_buff=MED_SMALL_BUFF, + **kwargs, + ): + self.scale_factor = scale_factor + self.include_underline = include_underline + self.match_underline_width_to_text = match_underline_width_to_text + self.underline_buff = underline_buff + Tex.__init__(self, *text_parts, **kwargs) + self.scale(self.scale_factor) + self.to_edge(UP) + if self.include_underline: + underline_width = config["frame_width"] - 2 + underline = OpenGLLine(LEFT, RIGHT) + underline.next_to(self, DOWN, buff=self.underline_buff) + if self.match_underline_width_to_text: + underline.match_width(self) + else: + underline.width = underline_width + self.add(underline) + self.underline = underline + + +class OpenGLTexMobject(OpenGLMathTex): + def __init__(self, *tex_strings, **kwargs): + logger.warning( + "TexMobject has been deprecated (due to its confusing name) " + "in favour of MathTex. Please use MathTex instead!" + ) + MathTex.__init__(self, *tex_strings, **kwargs) + + +class OpenGLTextMobject(OpenGLTex): + def __init__(self, *text_parts, **kwargs): + logger.warning( + "TextMobject has been deprecated (due to its confusing name) " + "in favour of Tex. Please use Tex instead!" + ) + Tex.__init__(self, *text_parts, **kwargs) diff --git a/manim/mobject/svg/opengl_text_mobject.py b/manim/mobject/svg/opengl_text_mobject.py new file mode 100644 index 0000000000..1955e09493 --- /dev/null +++ b/manim/mobject/svg/opengl_text_mobject.py @@ -0,0 +1,1219 @@ +"""Mobjects used for displaying (non-LaTeX) text. + +The simplest way to add text to your animations is to use the :class:`~.Text` class. It uses the Pango library to render text. +With Pango, you are also able to render non-English alphabets like `你好` or `こんにちは` or `안녕하세요` or `مرحبا بالعالم`. + +Examples +-------- + +.. manim:: HelloWorld + :save_last_frame: + + class HelloWorld(Scene): + def construct(self): + text = Text('Hello world').scale(3) + self.add(text) + +.. manim:: TextAlignment + :save_last_frame: + + class TextAlignment(Scene): + def construct(self): + title = Text("K-means clustering and Logistic Regression", color=WHITE) + title.scale_in_place(0.75) + self.add(title.to_edge(UP)) + + t1 = Text("1. Measuring").set_color(WHITE) + t1.next_to(ORIGIN, direction=RIGHT, aligned_edge=UP) + + t2 = Text("2. Clustering").set_color(WHITE) + t2.next_to(t1, direction=DOWN, aligned_edge=LEFT) + + t3 = Text("3. Regression").set_color(WHITE) + t3.next_to(t2, direction=DOWN, aligned_edge=LEFT) + + t4 = Text("4. Prediction").set_color(WHITE) + t4.next_to(t3, direction=DOWN, aligned_edge=LEFT) + + x = VGroup(t1, t2, t3, t4).scale_in_place(0.7) + x.set_opacity(0.5) + x.submobjects[1].set_opacity(1) + self.add(x) + +""" + +# __all__ = ["Text", "Paragraph", "CairoText", "MarkupText", "register_font"] + + +import copy +import hashlib +import os +import re +import sys +import typing +from contextlib import contextmanager +from pathlib import Path +from typing import Dict +from xml.sax.saxutils import escape + +import cairo +import manimpango +from manimpango import MarkupUtils, PangoUtils, TextSetting + +from ... import config, logger +from ...constants import * +from ...mobject.opengl_geometry import OpenGLDot +from ...mobject.svg.opengl_svg_mobject import OpenGLSVGMobject +from ...mobject.types.opengl_vectorized_mobject import OpenGLVGroup +from ...utils.color import WHITE, Colors + +TEXT_MOB_SCALE_FACTOR = 0.05 + + +def remove_invisible_chars(mobject): + """Function to remove unwanted invisible characters from some mobject + + Parameters + ---------- + mobject : :class:`~.SVGMobject` + Any SVGMobject from which we want to remove unwanted invisible characters. + + Returns + ------- + :class:`~.SVGMobject` + The SVGMobject without unwanted invisible characters. + """ + + iscode = False + if mobject.__class__.__name__ == "Text": + mobject = mobject[:] + elif mobject.__class__.__name__ == "Code": + iscode = True + code = mobject + mobject = mobject.code + mobject_without_dots = VGroup() + if mobject[0].__class__ == VGroup: + for i in range(mobject.__len__()): + mobject_without_dots.add(VGroup()) + mobject_without_dots[i].add(*[k for k in mobject[i] if k.__class__ != Dot]) + else: + mobject_without_dots.add(*[k for k in mobject if k.__class__ != Dot]) + if iscode: + code.code = mobject_without_dots + return code + return mobject_without_dots + + +class OpenGLParagraph(OpenGLVGroup): + r"""Display a paragraph of text. + + For a given :class:`.Paragraph` ``par``, the attribute ``par.chars`` is a + :class:`.VGroup` containing all the lines. In this context, every line is + constructed as a :class:`.VGroup` of characters contained in the line. + + + Parameters + ---------- + line_spacing : :class:`int`, optional + Represents the spacing between lines. Default to -1, which means auto. + alignment : :class:`str`, optional + Defines the alignment of paragraph. Default to "left". Possible values are "left", "right", "center" + + Examples + -------- + Normal usage:: + + paragraph = Paragraph('this is a awesome', 'paragraph', + 'With \nNewlines', '\tWith Tabs', + ' With Spaces', 'With Alignments', + 'center', 'left', 'right') + + Remove unwanted invisible characters:: + + self.play(Transform(remove_invisible_chars(paragraph.chars[0:2]), + remove_invisible_chars(paragraph.chars[3][0:3])) + + """ + + def __init__(self, *text, line_spacing=-1, alignment=None, **config): + self.line_spacing = line_spacing + self.alignment = alignment + OpenGLVGroup.__init__(self, **config) + + lines_str = "\n".join(list(text)) + self.lines_text = OpenGLText(lines_str, line_spacing=line_spacing, **config) + lines_str_list = lines_str.split("\n") + self.chars = self.gen_chars(lines_str_list) + + chars_lines_text_list = OpenGLVGroup() + char_index_counter = 0 + for line_index in range(lines_str_list.__len__()): + chars_lines_text_list.add( + self.lines_text[ + char_index_counter : char_index_counter + + lines_str_list[line_index].__len__() + + 1 + ] + ) + char_index_counter += lines_str_list[line_index].__len__() + 1 + self.lines = [] + self.lines.append([]) + for line_no in range(chars_lines_text_list.__len__()): + self.lines[0].append(chars_lines_text_list[line_no]) + self.lines_initial_positions = [] + for line_no in range(self.lines[0].__len__()): + self.lines_initial_positions.append(self.lines[0][line_no].get_center()) + self.lines.append([]) + self.lines[1].extend( + [self.alignment for _ in range(chars_lines_text_list.__len__())] + ) + OpenGLVGroup.__init__( + self, *[self.lines[0][i] for i in range(self.lines[0].__len__())], **config + ) + self.move_to(np.array([0, 0, 0])) + if self.alignment: + self.set_all_lines_alignments(self.alignment) + + def gen_chars(self, lines_str_list): + """Function to convert plain string to 2d-VGroup of chars. 2d-VGroup mean "VGroup of VGroup". + + Parameters + ---------- + lines_str_list : :class:`str` + Plain text string. + + Returns + ------- + :class:`~.VGroup` + The generated 2d-VGroup of chars. + """ + char_index_counter = 0 + chars = OpenGLVGroup() + for line_no in range(lines_str_list.__len__()): + chars.add(OpenGLVGroup()) + chars[line_no].add( + *self.lines_text.chars[ + char_index_counter : char_index_counter + + lines_str_list[line_no].__len__() + + 1 + ] + ) + char_index_counter += lines_str_list[line_no].__len__() + 1 + return chars + + def set_all_lines_alignments(self, alignment): + """Function to set all line's alignment to a specific value. + + Parameters + ---------- + alignment : :class:`str` + Defines the alignment of paragraph. Possible values are "left", "right", "center". + """ + for line_no in range(0, self.lines[0].__len__()): + self.change_alignment_for_a_line(alignment, line_no) + return self + + def set_line_alignment(self, alignment, line_no): + """Function to set one line's alignment to a specific value. + + Parameters + ---------- + alignment : :class:`str` + Defines the alignment of paragraph. Possible values are "left", "right", "center". + line_no : :class:`int` + Defines the line number for which we want to set given alignment. + """ + self.change_alignment_for_a_line(alignment, line_no) + return self + + def set_all_lines_to_initial_positions(self): + """Set all lines to their initial positions.""" + self.lines[1] = [None for _ in range(self.lines[0].__len__())] + for line_no in range(0, self.lines[0].__len__()): + self[line_no].move_to( + self.get_center() + self.lines_initial_positions[line_no] + ) + return self + + def set_line_to_initial_position(self, line_no): + """Function to set one line to initial positions. + + Parameters + ---------- + line_no : :class:`int` + Defines the line number for which we want to set given alignment. + """ + self.lines[1][line_no] = None + self[line_no].move_to(self.get_center() + self.lines_initial_positions[line_no]) + return self + + def change_alignment_for_a_line(self, alignment, line_no): + """Function to change one line's alignment to a specific value. + + Parameters + ---------- + alignment : :class:`str` + Defines the alignment of paragraph. Possible values are "left", "right", "center". + line_no : :class:`int` + Defines the line number for which we want to set given alignment. + """ + self.lines[1][line_no] = alignment + if self.lines[1][line_no] == "center": + self[line_no].move_to( + np.array([self.get_center()[0], self[line_no].get_center()[1], 0]) + ) + elif self.lines[1][line_no] == "right": + self[line_no].move_to( + np.array( + [ + self.get_right()[0] - self[line_no].width / 2, + self[line_no].get_center()[1], + 0, + ] + ) + ) + elif self.lines[1][line_no] == "left": + self[line_no].move_to( + np.array( + [ + self.get_left()[0] + self[line_no].width / 2, + self[line_no].get_center()[1], + 0, + ] + ) + ) + + +class OpenGLText(OpenGLSVGMobject): + r"""Display (non-LaTeX) text rendered using `Pango `_. + + Text objects behave like a :class:`.VGroup`-like iterable of all characters + in the given text. In particular, slicing is possible. + + Parameters + ---------- + text : :class:`str` + The text that need to created as mobject. + + Returns + ------- + :class:`Text` + The mobject like :class:`.VGroup`. + + Examples + --------- + + .. manim:: Example1Text + :save_last_frame: + + class Example1Text(Scene): + def construct(self): + text = Text('Hello world').scale(3) + self.add(text) + + .. manim:: TextColorExample + :save_last_frame: + + class TextColorExample(Scene): + def construct(self): + text1 = Text('Hello world', color=BLUE).scale(3) + text2 = Text('Hello world', gradient=(BLUE, GREEN)).scale(3).next_to(text1, DOWN) + self.add(text1, text2) + + .. manim:: TextItalicAndBoldExample + :save_last_frame: + + class TextItalicAndBoldExample(Scene): + def construct(self): + text0 = Text('Hello world', slant=ITALIC) + text1 = Text('Hello world', t2s={'world':ITALIC}) + text2 = Text('Hello world', weight=BOLD) + text3 = Text('Hello world', t2w={'world':BOLD}) + self.add(text0,text1, text2,text3) + for i,mobj in enumerate(self.mobjects): + mobj.shift(DOWN*(i-1)) + + + .. manim:: TextMoreCustomization + :save_last_frame: + + class TextMoreCustomization(Scene): + def construct(self): + text1 = Text( + 'Google', + t2c={'[:1]': '#3174f0', '[1:2]': '#e53125', + '[2:3]': '#fbb003', '[3:4]': '#3174f0', + '[4:5]': '#269a43', '[5:]': '#e53125'}, size=1.2).scale(3) + self.add(text1) + + As :class:`Text` uses Pango to render text, rendering non-English + characters is easily possible: + + .. manim:: MultipleFonts + :save_last_frame: + + class MultipleFonts(Scene): + def construct(self): + morning = Text("வணக்கம்", font="sans-serif") + chin = Text( + "見 角 言 谷 辛 辰 辵 邑 酉 釆 里!", t2c={"見 角 言": BLUE} + ) # works same as ``Text``. + mess = Text("Multi-Language", style=BOLD) + russ = Text("Здравствуйте मस नम म ", font="sans-serif") + hin = Text("नमस्ते", font="sans-serif") + arb = Text( + "صباح الخير \n تشرفت بمقابلتك", font="sans-serif" + ) # don't mix RTL and LTR languages nothing shows up then ;-) + japanese = Text("臂猿「黛比」帶著孩子", font="sans-serif") + self.add(morning,chin,mess,russ,hin,arb,japanese) + for i,mobj in enumerate(self.mobjects): + mobj.shift(DOWN*(i-3)) + + + .. manim:: PangoRender + :quality: low + + class PangoRender(Scene): + def construct(self): + morning = Text("வணக்கம்", font="sans-serif") + self.play(Write(morning)) + self.wait(2) + + Tests + ----- + + Check that the creation of :class:`~.Text` works:: + + >>> Text('The horse does not eat cucumber salad.') + Text('The horse does not eat cucumber salad.') + + """ + + def __init__( + self, + text: str, + fill_opacity: float = 1.0, + stroke_width: int = 0, + color: str = WHITE, + size: int = 1, + line_spacing: int = -1, + font: str = "", + slant: str = NORMAL, + weight: str = NORMAL, + t2c: Dict[str, str] = None, + t2f: Dict[str, str] = None, + t2g: Dict[str, tuple] = None, + t2s: Dict[str, str] = None, + t2w: Dict[str, str] = None, + gradient: tuple = None, + tab_width: int = 4, + # Mobject + height: int = None, + width: int = None, + should_center: bool = True, + unpack_groups: bool = True, + disable_ligatures: bool = False, + **kwargs, + ): + self.size = size + self.line_spacing = line_spacing + self.font = font + self.slant = slant + self.weight = weight + self.gradient = gradient + self.tab_width = tab_width + if t2c is None: + t2c = {} + if t2f is None: + t2f = {} + if t2g is None: + t2g = {} + if t2s is None: + t2s = {} + if t2w is None: + t2w = {} + # If long form arguments are present, they take precedence + t2c = kwargs.pop("text2color", t2c) + t2f = kwargs.pop("text2font", t2f) + t2g = kwargs.pop("text2gradient", t2g) + t2s = kwargs.pop("text2slant", t2s) + t2w = kwargs.pop("text2weight", t2w) + self.t2c = t2c + self.t2f = t2f + self.t2g = t2g + self.t2s = t2s + self.t2w = t2w + + self.original_text = text + self.disable_ligatures = disable_ligatures + text_without_tabs = text + if text.find("\t") != -1: + text_without_tabs = text.replace("\t", " " * self.tab_width) + self.text = text_without_tabs + if self.line_spacing == -1: + self.line_spacing = self.size + self.size * 0.3 + else: + self.line_spacing = self.size + self.size * self.line_spacing + file_name = self.text2svg() + PangoUtils.remove_last_M(file_name) + OpenGLSVGMobject.__init__( + self, + file_name, + color=color, + fill_opacity=fill_opacity, + stroke_width=stroke_width, + height=height, + width=width, + should_center=should_center, + unpack_groups=unpack_groups, + **kwargs, + ) + self.text = text + if self.disable_ligatures: + self.submobjects = [*self.gen_chars()] + self.chars = OpenGLVGroup(*self.submobjects) + self.text = text_without_tabs.replace(" ", "").replace("\n", "") + if config["renderer"] == "opengl": + nppc = self.n_points_per_curve + else: + nppc = self.n_points_per_cubic_curve + for each in self: + if config["renderer"] == "opengl": + points = each.data["points"] + else: + nppc = each.points + if len(points) == 0: + continue + last = points[0] + each.clear_points() + for index, point in enumerate(points): + each.append_points([point]) + if ( + index != len(points) - 1 + and (index + 1) % nppc == 0 + and any(point != points[index + 1]) + ): + each.add_line_to(last) + last = points[index + 1] + each.add_line_to(last) + 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 height is None and width is None: + self.scale(TEXT_MOB_SCALE_FACTOR) + + def __repr__(self): + return f"Text({repr(self.original_text)})" + + def gen_chars(self): + chars = OpenGLVGroup() + submobjects_char_index = 0 + for char_index in range(self.text.__len__()): + if self.text[char_index] in (" ", "\t", "\n"): + space = OpenGLDot(radius=0, fill_opacity=0, stroke_opacity=0) + if char_index == 0: + space.move_to(self.submobjects[submobjects_char_index].get_center()) + else: + space.move_to( + self.submobjects[submobjects_char_index - 1].get_center() + ) + chars.add(space) + else: + chars.add(self.submobjects[submobjects_char_index]) + submobjects_char_index += 1 + return chars + + def find_indexes(self, word: str, text: str): + """Internally used function. Finds the indexes of ``text`` in ``word``.""" + temp = re.match(r"\[([0-9\-]{0,}):([0-9\-]{0,})\]", word) + if temp: + start = int(temp.group(1)) if temp.group(1) != "" else 0 + end = int(temp.group(2)) if temp.group(2) != "" else len(text) + start = len(text) + start if start < 0 else start + end = len(text) + end if end < 0 else end + return [(start, end)] + indexes = [] + index = text.find(word) + while index != -1: + indexes.append((index, index + len(word))) + index = text.find(word, index + len(word)) + return indexes + + # def full2short(self, kwargs): + # """Internally used function. Formats some expansion to short forms. + # text2color -> t2c + # text2font -> t2f + # text2gradient -> t2g + # text2slant -> t2s + # text2weight -> t2w + # """ + # if "text2color" in kwargs: + # self.t2c = kwargs.pop("text2color") + # if "text2font" in kwargs: + # self.t2f = kwargs.pop("text2font") + # if "text2gradient" in kwargs: + # self.t2g = kwargs.pop("text2gradient") + # if "text2slant" in kwargs: + # self.t2s = kwargs.pop("text2slant") + # if "text2weight" in kwargs: + # self.t2w = kwargs.pop("text2weight") + + def set_color_by_t2c(self, t2c=None): + """Internally used function. Sets colour for specified strings.""" + t2c = t2c if t2c else self.t2c + for word, color in list(t2c.items()): + for start, end in self.find_indexes(word, self.original_text): + self.chars[start:end].set_color(color) + + def set_color_by_t2g(self, t2g=None): + """Internally used. Sets gradient colors for specified + strings. Behaves similarly to ``set_color_by_t2c``.""" + t2g = t2g if t2g else self.t2g + for word, gradient in list(t2g.items()): + for start, end in self.find_indexes(word, self.original_text): + self.chars[start:end].set_color_by_gradient(*gradient) + + def text2hash(self): + """Internally used function. + Generates ``sha256`` hash for file name. + """ + settings = ( + "PANGO" + self.font + self.slant + self.weight + ) # to differentiate Text and CairoText + settings += str(self.t2f) + str(self.t2s) + str(self.t2w) + settings += str(self.line_spacing) + str(self.size) + settings += str(self.disable_ligatures) + id_str = self.text + settings + hasher = hashlib.sha256() + hasher.update(id_str.encode()) + return hasher.hexdigest()[:16] + + def text2settings(self): + """Internally used function. Converts the texts and styles + to a setting for parsing.""" + 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, self.text): + 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", self.text): + 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): + """Internally used function. + Convert the text to SVG using Pango + """ + size = self.size * 10 + line_spacing = self.line_spacing * 10 + dir_name = config.get_dir("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 + settings = self.text2settings() + width = 600 + height = 400 + + return manimpango.text2svg( + settings, + size, + line_spacing, + disable_liga, + file_name, + START_X, + START_Y, + width, + height, + self.text, + ) + + def init_colors(self, propagate_colors=True): + OpenGLSVGMobject.set_style( + self, + fill_color=self.fill_color or self.color, + fill_opacity=self.fill_opacity, + stroke_color=self.stroke_color or self.color, + stroke_width=self.stroke_width, + stroke_opacity=self.stroke_opacity, + recurse=propagate_colors, + ) + + +class OpenGLMarkupText(OpenGLSVGMobject): + r"""Display (non-LaTeX) text rendered using `Pango `_. + + Text objects behave like a :class:`.VGroup`-like iterable of all characters + in the given text. In particular, slicing is possible. Text can be formatted + using different tags: + + - ``bold``, ``italic`` and ``bold+italic`` + - ```` and ``strike through`` + - ``typewriter font`` + - ``bigger font`` and ``smaller font`` + - ``superscript`` and ``subscript`` + - ``double underline`` + - ``error underline`` + - ``overline`` + - ``strikethrough`` + - ``temporary change of font`` + - ``temporary change of color`` + - ``temporary change of color`` + - ``temporary gradient`` + + For ```` markup, colors can be specified either as hex triples like ``#aabbcc`` or as named CSS colors like ``AliceBlue``. + The ```` tag being handled by Manim rather than Pango, supports hex triplets or Manim constants like ``RED`` or ``RED_A``. + If you want to use Manim constants like ``RED_A`` together with ````, + you will need to use Python's f-String syntax as follows: + ``f'here you go'`` + + If your text contains ligatures, the :class:`MarkupText` class may incorrectly determine + the first and last letter when creating the gradient. This is due to the fact that e.g. ``fl`` + are two characters, but might be set as one single glyph, a ligature. If your language does + not depend on ligatures, consider setting ``disable_ligatures=True``. If you cannot or do + not want to do without ligatures, the ``gradient`` tag supports an optional attribute ``offset`` + which can be used to compensate for that error. Usage is as follows: + + - ``example`` to *start* the gradient one letter earlier + - ``example`` to *end* the gradient one letter earlier + - ``example`` to *start* the gradient two letters earlier and *end* it one letter earlier + + Specifying a second offset may be necessary if the text to be colored does + itself contain ligatures. The same can happen when using HTML entities for + special chars. + + When using ``underline``, ``overline`` or ``strikethrough`` together with ```` tags, you will also need to use the offset, because + underlines are additional paths in the final :class:`SVGMobject`, check out the corresponding example. + + Escaping of special characters: ``>`` *should* be written as ``>`` whereas ``<`` and + ``&`` *must* be written as ``<`` and ``&``. + + You can find more information about Pango markup formatting at the + corresponding documentation page: + `Pango Markup `_. + Please be aware that not all features are supported by this class and that + the ```` tag mentioned above is not supported by Pango. + + Parameters + ---------- + text : :class:`str` + The text that need to created as mobject. + fill_opacity : :class:`int` + The fill opacity with 1 meaning opaque and 0 meaning transparent. + stroke_width : :class:`int` + Stroke width. + color : :class:`str` + Global color setting for the entire text. Local overrides are possible. + size : :class:`int` + Font size. + line_spacing : :class:`int` + Line spacing. + font : :class:`str` + Global font setting for the entire text. Local overrides are possible. + slant : :class:`str` + Global slant setting, e.g. `NORMAL` or `ITALIC`. Local overrides are possible. + weight : :class:`str` + Global weight setting, e.g. `NORMAL` or `BOLD`. Local overrides are possible. + gradient: :class:`tuple` + Global gradient setting. Local overrides are possible. + + + Returns + ------- + :class:`MarkupText` + The text displayed in form of a :class:`.VGroup`-like mobject. + + Examples + --------- + + .. manim:: BasicMarkupExample + :save_last_frame: + + class BasicMarkupExample(Scene): + def construct(self): + text1 = MarkupText("foo bar foobar") + text2 = MarkupText("foo bar big small") + text3 = MarkupText("H2O and H3O+") + text4 = MarkupText("type help for help") + text5 = MarkupText( + 'foo bar' + ) + group = VGroup(text1, text2, text3, text4, text5).arrange(DOWN) + self.add(group) + + .. manim:: ColorExample + :save_last_frame: + + class ColorExample(Scene): + def construct(self): + text1 = MarkupText( + f'all in red except this', color=RED + ) + text2 = MarkupText("nice gradient", gradient=(BLUE, GREEN)) + text3 = MarkupText( + 'nice intermediate gradient', + gradient=(BLUE, GREEN), + ) + text4 = MarkupText( + 'fl ligature causing trouble here' + ) + text5 = MarkupText( + 'fl ligature defeated with offset' + ) + text6 = MarkupText( + 'fl ligature floating inside' + ) + text7 = MarkupText( + 'fl ligature floating inside' + ) + group = VGroup(text1, text2, text3, text4, text5, text6, text7).arrange(DOWN) + self.add(group) + + .. manim:: UnderlineExample + :save_last_frame: + + class UnderlineExample(Scene): + def construct(self): + text1 = MarkupText( + 'bla' + ) + text2 = MarkupText( + 'xxxaabby' + ) + text3 = MarkupText( + 'xxxaabby' + ) + text4 = MarkupText( + 'xxxaabby' + ) + text5 = MarkupText( + 'xxxaabby' + ) + group = VGroup(text1, text2, text3, text4, text5).arrange(DOWN) + self.add(group) + + .. manim:: FontExample + :save_last_frame: + + class FontExample(Scene): + def construct(self): + text1 = MarkupText( + 'all in sans except this', font="sans" + ) + text2 = MarkupText( + 'mixing fonts is ugly' + ) + text3 = MarkupText("special char > or >") + text4 = MarkupText("special char < and &") + group = VGroup(text1, text2, text3, text4).arrange(DOWN) + self.add(group) + + .. manim:: NewlineExample + :save_last_frame: + + class NewlineExample(Scene): + def construct(self): + text = MarkupText('foooooo\nbaaaar') + self.add(text) + + .. manim:: NoLigaturesExample + :save_last_frame: + + class NoLigaturesExample(Scene): + def construct(self): + text1 = MarkupText('floating') + text2 = MarkupText('floating', disable_ligatures=True) + group = VGroup(text1, text2).arrange(DOWN) + self.add(group) + + + As :class:`MarkupText` uses Pango to render text, rendering non-English + characters is easily possible: + + .. manim:: MultiLanguage + :save_last_frame: + + class MultiLanguage(Scene): + def construct(self): + morning = MarkupText("வணக்கம்", font="sans-serif") + chin = MarkupText( + '見 角 言 谷 辛 辰 辵 邑 酉 釆 里!' + ) # works as in ``Text``. + mess = MarkupText("Multi-Language", style=BOLD) + russ = MarkupText("Здравствуйте मस नम म ", font="sans-serif") + hin = MarkupText("नमस्ते", font="sans-serif") + japanese = MarkupText("臂猿「黛比」帶著孩子", font="sans-serif") + group = VGroup(morning, chin, mess, russ, hin, japanese).arrange(DOWN) + self.add(group) + + + Tests + ----- + + Check that the creation of :class:`~.MarkupText` works:: + + >>> MarkupText('The horse does not eat cucumber salad.') + MarkupText('The horse does not eat cucumber salad.') + + """ + + def __init__( + self, + text: str, + fill_opacity: int = 1, + stroke_width: int = 0, + color: str = WHITE, + size: int = 1, + line_spacing: int = -1, + font: str = "", + slant: str = NORMAL, + weight: str = NORMAL, + gradient: tuple = None, + tab_width: int = 4, + height: int = None, + width: int = None, + should_center: bool = True, + unpack_groups: bool = True, + disable_ligatures: bool = False, + **kwargs, + ): + self.text = text + self.color = color + self.size = size + self.line_spacing = line_spacing + self.font = font + self.slant = slant + self.weight = weight + self.gradient = gradient + self.tab_width = tab_width + + self.original_text = text + self.disable_ligatures = disable_ligatures + text_without_tabs = text + if "\t" in text: + text_without_tabs = text.replace("\t", " " * self.tab_width) + + colormap = self.extract_color_tags() + if len(colormap) > 0: + logger.warning( + 'Using tags in MarkupText is deprecated. Please use instead.' + ) + gradientmap = self.extract_gradient_tags() + + if not MarkupUtils.validate(self.text): + raise ValueError( + f"Pango cannot parse your markup in {self.text}. " + "Please check for typos, unmatched tags or unescaped " + "special chars like < and &." + ) + + if self.line_spacing == -1: + self.line_spacing = self.size + self.size * 0.3 + else: + self.line_spacing = self.size + self.size * self.line_spacing + + file_name = self.text2svg() + PangoUtils.remove_last_M(file_name) + OpenGLSVGMobject.__init__( + self, + file_name, + fill_opacity=fill_opacity, + stroke_width=stroke_width, + height=height, + width=width, + should_center=should_center, + unpack_groups=unpack_groups, + **kwargs, + ) + self.chars = OpenGLVGroup(*self.submobjects) + self.text = text_without_tabs.replace(" ", "").replace("\n", "") + + if config["renderer"] == "opengl": + nppc = self.n_points_per_curve + else: + nppc = self.n_points_per_cubic_curve + for each in self: + if config["renderer"] == "opengl": + points = each.data["points"] + else: + points = each.points + if len(points) == 0: + continue + last = points[0] + each.clear_points() + for index, point in enumerate(points): + each.append_points([point]) + if ( + index != len(points) - 1 + and (index + 1) % nppc == 0 + and any(point != points[index + 1]) + ): + each.add_line_to(last) + last = points[index + 1] + each.add_line_to(last) + + 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 height is None and width 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.line_spacing) + str(self.size) + settings += str(self.disable_ligatures) + 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.size * 10 + line_spacing = self.line_spacing * 10 + dir_name = config.get_dir("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 + + logger.debug(f"Setting Text {self.text}") + return MarkupUtils.text2svg( + f'{self.text}', + self.font, + self.slant, + self.weight, + size, + line_spacing, + disable_liga, + file_name, + START_X, + START_Y, + 600, # width + 400, # height + ) + + def _count_real_chars(self, s): + """Counts characters that will be displayed. + + This is needed for partial coloring or gradients, because space + counts to the text's `len`, but has no corresponding character.""" + count = 0 + level = 0 + # temporarily replace HTML entities by single char + s = re.sub("&[^;]+;", "x", s) + for c in s: + if c == "<": + level += 1 + if c == ">" and level > 0: + level -= 1 + elif c != " " and c != "\t" and level == 0: + count += 1 + return count + + def extract_gradient_tags(self): + """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. + """ + tags = re.finditer( + r'(.+?)', + self.original_text, + re.S, + ) + gradientmap = [] + for tag in tags: + start = self._count_real_chars(self.original_text[: tag.start(0)]) + end = start + self._count_real_chars(tag.group(5)) + offsets = tag.group(4).split(",") if tag.group(4) 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": tag.group(1), + "to": tag.group(2), + "start_offset": start_offset, + "end_offset": end_offset, + } + ) + self.text = re.sub("]+>(.+?)", r"\1", self.text, 0, re.S) + return gradientmap + + def _parse_color(self, col): + """Parse color given in ```` or ```` tags.""" + if re.match("#[0-9a-f]{6}", col): + return col + else: + return Colors[col.lower()].value + + def extract_color_tags(self): + """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. + """ + tags = re.finditer( + r'(.+?)', + self.original_text, + re.S, + ) + + colormap = [] + for tag in tags: + start = self._count_real_chars(self.original_text[: tag.start(0)]) + end = start + self._count_real_chars(tag.group(4)) + offsets = tag.group(3).split(",") if tag.group(3) 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": tag.group(1), + "start_offset": start_offset, + "end_offset": end_offset, + } + ) + self.text = re.sub("]+>(.+?)", r"\1", self.text, 0, re.S) + return colormap + + 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. In ``assets/fonts`` folder. + 3. In ``font/`` folder. + 4. In the same directory. + + 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(config.input_file).parent.resolve() + possible_paths = [ + Path(font_file), + input_folder / "assets/fonts" / font_file, + input_folder / "fonts" / font_file, + input_folder / font_file, + ] + for path in possible_paths: + path = path.resolve() + if path.exists(): + file_path = path + logger.debug("Found file at %s", file_path.absolute()) + 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)) diff --git a/manim/mobject/svg/svg_mobject.py b/manim/mobject/svg/svg_mobject.py index 8bab13cbb7..f28e4f148c 100644 --- a/manim/mobject/svg/svg_mobject.py +++ b/manim/mobject/svg/svg_mobject.py @@ -18,6 +18,8 @@ from ... import config from ...constants import * from ...mobject.geometry import Circle, Line, Rectangle, RoundedRectangle +from ...mobject.opengl_geometry import OpenGLRectangle, OpenGLRoundedRectangle +from ...mobject.types.opengl_vectorized_mobject import OpenGLVGroup from ...mobject.types.vectorized_mobject import VGroup, VMobject from .style_utils import cascade_element_style, parse_style from .svg_path import SVGPathMobject, string_to_numbers @@ -197,9 +199,15 @@ def get_mobjects_from( pass # TODO result = [m for m in result if m is not None] - self.handle_transforms(element, VGroup(*result)) + if config["renderer"] == "opengl": + self.handle_transforms(element, OpenGLVGroup(*result)) + else: + self.handle_transforms(element, VGroup(*result)) if len(result) > 1 and not self.unpack_groups: - result = [VGroup(*result)] + if config["renderer"] == "opengl": + result = [OpenGLVGroup(*result)] + else: + result = [VGroup(*result)] if within_defs and element.hasAttribute("id"): # it seems wasteful to throw away the actual element, @@ -343,18 +351,33 @@ def rect_to_mobject(self, rect_element: MinidomElement, style: dict): parsed_style["stroke_width"] = stroke_width if corner_radius == 0: - mob = Rectangle( - width=self.attribute_to_float(rect_element.getAttribute("width")), - height=self.attribute_to_float(rect_element.getAttribute("height")), - **parsed_style, - ) + if config["renderer"] == "opengl": + mob = OpenGLRectangle( + width=self.attribute_to_float(rect_element.getAttribute("width")), + height=self.attribute_to_float(rect_element.getAttribute("height")), + **parsed_style, + ) + else: + mob = Rectangle( + width=self.attribute_to_float(rect_element.getAttribute("width")), + height=self.attribute_to_float(rect_element.getAttribute("height")), + **parsed_style, + ) else: - mob = RoundedRectangle( - width=self.attribute_to_float(rect_element.getAttribute("width")), - height=self.attribute_to_float(rect_element.getAttribute("height")), - corner_radius=corner_radius, - **parsed_style, - ) + if config["renderer"] == "opengl": + mob = OpenGLRoundedRectangle( + width=self.attribute_to_float(rect_element.getAttribute("width")), + height=self.attribute_to_float(rect_element.getAttribute("height")), + corner_radius=corner_radius, + **parsed_style, + ) + else: + mob = RoundedRectangle( + width=self.attribute_to_float(rect_element.getAttribute("width")), + height=self.attribute_to_float(rect_element.getAttribute("height")), + corner_radius=corner_radius, + **parsed_style, + ) mob.shift(mob.get_center() - mob.get_corner(UP + LEFT)) return mob @@ -487,7 +510,10 @@ def handle_transforms(self, element, mobject): matrix[:, 1] *= -1 for mob in mobject.family_members_with_points(): - mob.points = np.dot(mob.points, matrix) + if config["renderer"] == "opengl": + mob.data["points"] = np.dot(mob.data["points"], matrix) + else: + mob.points = np.dot(mob.points, matrix) mobject.shift(x * RIGHT + y * UP) elif op_name == "scale": diff --git a/manim/mobject/svg/svg_path.py b/manim/mobject/svg/svg_path.py index a1d2007723..cd78712998 100644 --- a/manim/mobject/svg/svg_path.py +++ b/manim/mobject/svg/svg_path.py @@ -9,6 +9,7 @@ from manim import logger +from ... import config from ...constants import * from ...mobject.types.vectorized_mobject import VMobject @@ -88,6 +89,13 @@ def generate_points(self): for command, coord_string in pairs: self.handle_command(command, coord_string, prev_command) prev_command = command + if config["renderer"] == "opengl": + if self.should_subdivide_sharp_curves: + # For a healthy triangulation later + self.subdivide_sharp_curves() + if self.should_remove_null_curves: + # Get rid of any null curves + self.set_points(self.get_points_without_null_curves()) # people treat y-coordinate differently self.rotate(np.pi, RIGHT, about_point=ORIGIN) @@ -98,9 +106,11 @@ def handle_command(self, command, coord_string, prev_command): command = command.upper() # Keep track of the most recently completed point - start_point = ( - self.points[-1] if self.points.shape[0] else np.zeros((1, self.dim)) - ) + if config["renderer"] == "opengl": + points = self.data["points"] + else: + points = self.points + start_point = points[-1] if points.shape[0] else np.zeros((1, self.dim)) # Produce the (absolute) coordinates of the controls and handles new_points = self.string_to_points( @@ -125,9 +135,13 @@ def handle_command(self, command, coord_string, prev_command): return elif command == "S": # Smooth cubic + if config["renderer"] == "opengl": + points = self.data["points"] + else: + points = self.points prev_handle = start_point if prev_command.upper() in ["C", "S"]: - prev_handle = self.points[-2] + prev_handle = points[-2] for i in range(0, len(new_points), 2): new_handle = 2 * start_point - prev_handle self.add_cubic_bezier_curve_to( @@ -147,7 +161,7 @@ def handle_command(self, command, coord_string, prev_command): if prev_command.upper() in ["Q", "T"]: # because of the conversion from quadratic to cubic, # our actual previous handle was 3/2 in the direction of p[-2] from p[-1] - prev_quad_handle = 1.5 * self.points[-2] - 0.5 * self.points[-1] + prev_quad_handle = 1.5 * points[-2] - 0.5 * points[-1] for p in new_points: new_quad_handle = 2 * start_point - prev_quad_handle self.add_quadratic_bezier_curve_to(new_quad_handle, p) @@ -158,7 +172,10 @@ def handle_command(self, command, coord_string, prev_command): raise NotImplementedError() elif command == "Z": # closepath - self.add_line_to(self.current_path_start) + if config["renderer"] == "opengl": + self.close_path() + else: + self.add_line_to(self.current_path_start) return def string_to_points(self, command, is_relative, coord_string, start_point): diff --git a/manim/mobject/types/opengl_vectorized_mobject.py b/manim/mobject/types/opengl_vectorized_mobject.py index 3abaecffb3..171ae46af0 100644 --- a/manim/mobject/types/opengl_vectorized_mobject.py +++ b/manim/mobject/types/opengl_vectorized_mobject.py @@ -22,9 +22,8 @@ # from manimlib.utils.iterables import resize_array # from manimlib.utils.color import rgb_to_hex from ...utils.iterables import listify, make_even, resize_with_interpolation - -# from manimlib.utils.space_ops import angle_between_vectors from ...utils.space_ops import ( + angle_between_vectors, cross2d, earclip_triangulation, get_norm, @@ -77,6 +76,8 @@ def __init__( tolerance_for_point_equality=1e-8, n_points_per_curve=3, long_lines=False, + should_subdivide_sharp_curves=False, + should_remove_null_curves=False, # Could also be "bevel", "miter", "round" joint_type="auto", flat_stroke=True, @@ -103,6 +104,8 @@ def __init__( self.tolerance_for_point_equality = tolerance_for_point_equality self.n_points_per_curve = n_points_per_curve self.long_lines = long_lines + self.should_subdivide_sharp_curves = should_subdivide_sharp_curves + self.should_remove_null_curves = should_remove_null_curves # Could also be "bevel", "miter", "round" self.joint_type = joint_type self.flat_stroke = flat_stroke @@ -115,10 +118,8 @@ def __init__( super().__init__(**kwargs) self.refresh_unit_normal() - # - # def get_group_class(self): - # return VGroup - # + def get_group_class(self): + return OpenGLVGroup def init_data(self): super().init_data() diff --git a/manim/opengl/__init__.py b/manim/opengl/__init__.py index 00298e0b64..6dbdf520f6 100644 --- a/manim/opengl/__init__.py +++ b/manim/opengl/__init__.py @@ -1,5 +1,8 @@ from ..mobject.opengl_geometry import * from ..mobject.opengl_mobject import * from ..mobject.opengl_three_dimensions import * +from ..mobject.svg.opengl_svg_mobject import * +from ..mobject.svg.opengl_tex_mobject import * +from ..mobject.svg.opengl_text_mobject import * from ..mobject.types.opengl_surface import * from ..mobject.types.opengl_vectorized_mobject import * diff --git a/manim/utils/space_ops.py b/manim/utils/space_ops.py index 2d0d807e79..f7ba46a4be 100644 --- a/manim/utils/space_ops.py +++ b/manim/utils/space_ops.py @@ -228,7 +228,11 @@ def angle_between_vectors(v1, v2): Returns the angle between two 3D vectors. This angle will always be btw 0 and pi """ - return np.arccos(fdiv(np.dot(v1, v2), get_norm(v1) * get_norm(v2))) + if config["renderer"] == "opengl": + diff = (angle_of_vector(v2) - angle_of_vector(v1)) % TAU + return min(diff, TAU - diff) + else: + return np.arccos(fdiv(np.dot(v1, v2), get_norm(v1) * get_norm(v2))) def project_along_vector(point, vector): diff --git a/tests/utils/GraphicalUnitTester.py b/tests/utils/GraphicalUnitTester.py index 685823dd6d..f6163f9ef5 100644 --- a/tests/utils/GraphicalUnitTester.py +++ b/tests/utils/GraphicalUnitTester.py @@ -62,7 +62,7 @@ def __init__(self, scene_class, module_tested, tmpdir, rgb_atol=0): os.makedirs(dir_temp) with tempconfig({"dry_run": True}): - if config["use_opengl_renderer"]: + if config["renderer"] == "opengl": self.scene = scene_class(renderer=OpenGLRenderer()) else: self.scene = scene_class(skip_animations=True)