"""Mobjects used for displaying (non-LaTeX) text.
.. note::
Just as you can use :class:`~.Tex` and :class:`~.MathTex` (from the module :mod:`~.tex_mobject`)
to insert LaTeX to your videos, you can use :class:`~.Text` to to add normal text.
.. important::
See the corresponding tutorial :ref:`using-text-objects`, especially for information about fonts.
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(0.75)
self.add(title.to_edge(UP))
t1 = Text("1. Measuring").set_color(WHITE)
t2 = Text("2. Clustering").set_color(WHITE)
t3 = Text("3. Regression").set_color(WHITE)
t4 = Text("4. Prediction").set_color(WHITE)
x = VGroup(t1, t2, t3, t4).arrange(direction=DOWN, aligned_edge=LEFT).scale(0.7).next_to(ORIGIN,DR)
x.set_opacity(0.5)
x.submobjects[1].set_opacity(1)
self.add(x)
"""
from __future__ import annotations
import functools
__all__ = ["Text", "Paragraph", "MarkupText", "register_font"]
import copy
import hashlib
import re
from collections.abc import Iterable, Iterator, Sequence
from contextlib import contextmanager
from itertools import chain
from pathlib import Path
from typing import TYPE_CHECKING, Any
import manimpango
import numpy as np
from manimpango import MarkupUtils, PangoUtils, TextSetting
from manim import config, logger
from manim.constants import *
from manim.mobject.geometry.arc import Dot
from manim.mobject.svg.svg_mobject import SVGMobject
from manim.mobject.types.vectorized_mobject import VGroup, VMobject
from manim.typing import Point3D
from manim.utils.color import ManimColor, ParsableManimColor, color_gradient
if TYPE_CHECKING:
from typing import Self
from manim.typing import Point3D
TEXT_MOB_SCALE_FACTOR = 0.05
DEFAULT_LINE_SPACING_SCALE = 0.3
TEXT2SVG_ADJUSTMENT_FACTOR = 4.8
__all__ = ["Text", "Paragraph", "MarkupText", "register_font"]
[docs]
def remove_invisible_chars(mobject: VMobject) -> VMobject:
"""Function to remove unwanted invisible characters from some mobjects.
Parameters
----------
mobject
Any SVGMobject from which we want to remove unwanted invisible characters.
Returns
-------
:class:`~.SVGMobject`
The SVGMobject without unwanted invisible characters.
"""
mobject_without_dots = VGroup()
if isinstance(mobject[0], VGroup):
for submob in mobject:
mobject_without_dots.add(
VGroup(k for k in submob if not isinstance(k, Dot))
)
else:
mobject_without_dots.add(*(k for k in mobject if not isinstance(k, Dot)))
return mobject_without_dots
[docs]
class Paragraph(VGroup):
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
Represents the spacing between lines. Defaults to -1, which means auto.
alignment
Defines the alignment of paragraph. Defaults to None. Possible values are "left", "right" or "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: str,
line_spacing: float = -1,
alignment: str | None = None,
**kwargs: Any,
):
self.line_spacing = line_spacing
self.alignment = alignment
self.consider_spaces_as_chars = kwargs.get("disable_ligatures", False)
super().__init__()
lines_str = "\n".join(list(text))
self.lines_text = Text(lines_str, line_spacing=line_spacing, **kwargs)
lines_str_list = lines_str.split("\n")
self.chars = self._gen_chars(lines_str_list)
self.lines = [list(self.chars), [self.alignment] * len(self.chars)]
self.lines_initial_positions = [line.get_center() for line in self.lines[0]]
self.add(*self.lines[0])
self.move_to(np.array([0, 0, 0]))
if self.alignment:
self._set_all_lines_alignments(self.alignment)
[docs]
def _gen_chars(self, lines_str_list: list) -> VGroup:
"""Function to convert a list of plain strings to a VGroup of VGroups of chars.
Parameters
----------
lines_str_list
List of plain text strings.
Returns
-------
:class:`~.VGroup`
The generated 2d-VGroup of chars.
"""
char_index_counter = 0
chars = self.get_group_class()()
for line_no in range(len(lines_str_list)):
line_str = lines_str_list[line_no]
# Count all the characters in line_str
# Spaces may or may not count as characters
if self.consider_spaces_as_chars:
char_count = len(line_str)
else:
char_count = 0
for char in line_str:
if not char.isspace():
char_count += 1
chars.add(self.get_group_class()())
chars[line_no].add(
*self.lines_text.chars[
char_index_counter : char_index_counter + char_count
]
)
char_index_counter += char_count
if self.consider_spaces_as_chars:
# If spaces count as characters, count the extra \n character
# which separates Paragraph's lines to avoid issues
char_index_counter += 1
return chars
[docs]
def _set_all_lines_alignments(self, alignment: str) -> Paragraph:
"""Function to set all line's alignment to a specific value.
Parameters
----------
alignment
Defines the alignment of paragraph. Possible values are "left", "right", "center".
"""
for line_no in range(len(self.lines[0])):
self._change_alignment_for_a_line(alignment, line_no)
return self
[docs]
def _set_line_alignment(self, alignment: str, line_no: int) -> Paragraph:
"""Function to set one line's alignment to a specific value.
Parameters
----------
alignment
Defines the alignment of paragraph. Possible values are "left", "right", "center".
line_no
Defines the line number for which we want to set given alignment.
"""
self._change_alignment_for_a_line(alignment, line_no)
return self
[docs]
def _set_all_lines_to_initial_positions(self) -> Paragraph:
"""Set all lines to their initial positions."""
self.lines[1] = [None] * len(self.lines[0])
for line_no in range(len(self.lines[0])):
self[line_no].move_to(
self.get_center() + self.lines_initial_positions[line_no],
)
return self
[docs]
def _set_line_to_initial_position(self, line_no: int) -> Paragraph:
"""Function to set one line to initial positions.
Parameters
----------
line_no
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
[docs]
def _change_alignment_for_a_line(self, alignment: str, line_no: int) -> None:
"""Function to change one line's alignment to a specific value.
Parameters
----------
alignment
Defines the alignment of paragraph. Possible values are "left", "right", "center".
line_no
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,
],
),
)
[docs]
class Text(SVGMobject):
r"""Display (non-LaTeX) text rendered using `Pango <https://pango.org/>`_.
Text objects behave like a :class:`.VGroup`-like iterable of all characters
in the given text. In particular, slicing is possible.
Parameters
----------
text
The text that needs to be created as a mobject.
font
The font family to be used to render the text. This is either a system font or
one loaded with `register_font()`. Note that font family names may be different
across operating systems.
warn_missing_font
If True (default), Manim will issue a warning if the font does not exist in the
(case-sensitive) list of fonts returned from `manimpango.list_fonts()`.
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):
text1 = Text("Hello world", slant=ITALIC)
text2 = Text("Hello world", t2s={'world':ITALIC})
text3 = Text("Hello world", weight=BOLD)
text4 = Text("Hello world", t2w={'world':BOLD})
text5 = Text("Hello world", t2c={'o':YELLOW}, disable_ligatures=True)
text6 = Text(
"Visit us at docs.manim.community",
t2c={"docs.manim.community": YELLOW},
disable_ligatures=True,
)
text6.scale(1.3).shift(DOWN)
self.add(text1, text2, text3, text4, text5 , text6)
Group(*self.mobjects).arrange(DOWN, buff=.8).set(height=config.frame_height-LARGE_BUFF)
.. 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'}, font_size=58).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")
japanese = Text(
"日本へようこそ", t2c={"日本": BLUE}
) # works same as ``Text``.
mess = Text("Multi-Language", weight=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 ;-)
chinese = Text("臂猿「黛比」帶著孩子", font="sans-serif")
self.add(morning, japanese, mess, russ, hin, arb, chinese)
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.')
"""
@staticmethod
@functools.cache
def font_list() -> list[str]:
value: list[str] = manimpango.list_fonts()
return value
def __init__(
self,
text: str,
fill_opacity: float = 1.0,
stroke_width: float = 0,
color: ParsableManimColor | None = None,
font_size: float = DEFAULT_FONT_SIZE,
line_spacing: float = -1,
font: str = "",
slant: str = NORMAL,
weight: str = NORMAL,
t2c: dict[str, str] | None = None,
t2f: dict[str, str] | None = None,
t2g: dict[str, Iterable[ParsableManimColor]] | None = None,
t2s: dict[str, str] | None = None,
t2w: dict[str, str] | None = None,
gradient: Iterable[ParsableManimColor] | None = None,
tab_width: int = 4,
warn_missing_font: bool = True,
# Mobject
height: float | None = None,
width: float | None = None,
should_center: bool = True,
disable_ligatures: bool = False,
use_svg_cache: bool = False,
**kwargs: Any,
):
self.line_spacing = line_spacing
if font and warn_missing_font:
fonts_list = Text.font_list()
# handle special case of sans/sans-serif
if font.lower() == "sans-serif":
font = "sans"
if font not in fonts_list:
# check if the capitalized version is in the supported fonts
if font.capitalize() in fonts_list:
font = font.capitalize()
elif font.lower() in fonts_list:
font = font.lower()
elif font.title() in fonts_list:
font = font.title()
else:
logger.warning(f"Font {font} not in {fonts_list}.")
self.font = font
self._font_size = float(font_size)
# needs to be a float or else size is inflated when font_size = 24
# (unknown cause)
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)
assert t2c is not None
assert t2f is not None
assert t2g is not None
assert t2s is not None
assert t2w is not None
self.t2c: dict[str, str] = {k: ManimColor(v).to_hex() for k, v in t2c.items()}
self.t2f: dict[str, str] = t2f
self.t2g: dict[str, Iterable[ParsableManimColor]] = t2g
self.t2s: dict[str, str] = t2s
self.t2w: dict[str, str] = 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._font_size + self._font_size * DEFAULT_LINE_SPACING_SCALE
)
else:
self.line_spacing = self._font_size + self._font_size * self.line_spacing
parsed_color: ManimColor = ManimColor(color) if color else VMobject().color
file_name = self._text2svg(parsed_color.to_hex())
PangoUtils.remove_last_M(file_name)
super().__init__(
file_name,
fill_opacity=fill_opacity,
stroke_width=stroke_width,
height=height,
width=width,
should_center=should_center,
use_svg_cache=use_svg_cache,
**kwargs,
)
self.text = text
if self.disable_ligatures:
self.submobjects = [*self._gen_chars()]
self.chars = self.get_group_class()(*self.submobjects)
self.text = text_without_tabs.replace(" ", "").replace("\n", "")
nppc = self.n_points_per_curve
for each in self:
if len(each.points) == 0:
continue
points = each.points
curve_start = points[0]
assert len(curve_start) == self.dim, curve_start
# Some of the glyphs in this text might not be closed,
# so we close them by identifying when one curve ends
# but it is not where the next curve starts.
# It is more efficient to temporarily create a list
# of points and add them one at a time, then turn them
# into a numpy array at the end, rather than creating
# new numpy arrays every time a point or fixing line
# is added (which is O(n^2) for numpy arrays).
closed_curve_points: list[Point3D] = []
# OpenGL has points be part of quadratic Bezier curves;
# Cairo uses cubic Bezier curves.
if nppc == 3: # RendererType.OPENGL
def add_line_to(end: Point3D) -> None:
nonlocal closed_curve_points
start = closed_curve_points[-1]
closed_curve_points += [
start,
(start + end) / 2,
end,
]
else: # RendererType.CAIRO
def add_line_to(end: Point3D) -> None:
nonlocal closed_curve_points
start = closed_curve_points[-1]
closed_curve_points += [
start,
(start + start + end) / 3,
(start + end + end) / 3,
end,
]
for index, point in enumerate(points):
closed_curve_points.append(point)
if (
index != len(points) - 1
and (index + 1) % nppc == 0
and any(point != points[index + 1])
):
# Add straight line from last point on this curve to the
# start point on the next curve. We represent the line
# as a cubic bezier curve where the two control points
# are half-way between the start and stop point.
add_line_to(curve_start)
curve_start = points[index + 1]
# Make sure last curve is closed
add_line_to(curve_start)
each.points = np.array(closed_curve_points, ndmin=2)
# anti-aliasing
if height is None and width is None:
self.scale(TEXT_MOB_SCALE_FACTOR)
self.initial_height = self.height
def __repr__(self) -> str:
return f"Text({repr(self.original_text)})"
@property
def font_size(self) -> float:
return (
self.height
/ self.initial_height
/ TEXT_MOB_SCALE_FACTOR
* 2.4
* self._font_size
/ DEFAULT_FONT_SIZE
)
@font_size.setter
def font_size(self, font_val: float) -> None:
# TODO: use pango's font size scaling.
if font_val <= 0:
raise ValueError("font_size must be greater than 0.")
else:
self.scale(font_val / self.font_size)
def _gen_chars(self) -> VGroup:
chars = self.get_group_class()()
submobjects_char_index = 0
for char_index in range(len(self.text)):
if self.text[char_index].isspace():
space = Dot(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
[docs]
def _find_indexes(self, word: str, text: str) -> list[tuple[int, int]]:
"""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
[docs]
def _text2hash(self, color: ParsableManimColor) -> str:
"""Generates ``sha256`` hash for file name."""
settings = (
"PANGO" + self.font + self.slant + self.weight + str(color)
) # to differentiate Text and CairoText
settings += str(self.t2f) + str(self.t2s) + str(self.t2w) + str(self.t2c)
settings += str(self.line_spacing) + str(self._font_size)
settings += str(self.disable_ligatures)
settings += str(self.gradient)
id_str = self.text + settings
hasher = hashlib.sha256()
hasher.update(id_str.encode())
return hasher.hexdigest()[:16]
def _merge_settings(
self,
left_setting: TextSetting,
right_setting: TextSetting,
default_args: dict[str, Iterable[str]],
) -> TextSetting:
contained = right_setting.end < left_setting.end
new_setting = copy.copy(left_setting) if contained else copy.copy(right_setting)
new_setting.start = right_setting.end if contained else left_setting.end
left_setting.end = right_setting.start
if not contained:
right_setting.end = new_setting.start
for arg in default_args:
left = getattr(left_setting, arg)
right = getattr(right_setting, arg)
default = default_args[arg]
if left != default and getattr(right_setting, arg) != default:
raise ValueError(
f"Ambiguous style for text '{self.text[right_setting.start : right_setting.end]}':"
+ f"'{arg}' cannot be both '{left}' and '{right}'."
)
setattr(right_setting, arg, left if left != default else right)
return new_setting
def _get_settings_from_t2xs(
self,
t2xs: Sequence[tuple[dict[str, str], str]],
default_args: dict[str, Iterable[str]],
) -> list[TextSetting]:
settings = []
t2xwords = set(chain(*([*t2x.keys()] for t2x, _ in t2xs)))
for word in t2xwords:
setting_args = {
arg: str(t2x[word]) if word in t2x else default_args[arg]
# NOTE: when t2x[word] is a ManimColor, str will yield the
# hex representation
for t2x, arg in t2xs
}
for start, end in self._find_indexes(word, self.text):
settings.append(TextSetting(start, end, **setting_args))
return settings
def _get_settings_from_gradient(
self, default_args: dict[str, Any]
) -> list[TextSetting]:
settings = []
args = copy.copy(default_args)
if self.gradient:
colors: list[ManimColor] = color_gradient(self.gradient, len(self.text))
for i in range(len(self.text)):
args["color"] = colors[i].to_hex()
settings.append(TextSetting(i, i + 1, **args))
for word, gradient in self.t2g.items():
colors = color_gradient(gradient, len(word))
for start, end in self._find_indexes(word, self.text):
for i in range(start, end):
args["color"] = colors[i - start].to_hex()
settings.append(TextSetting(i, i + 1, **args))
return settings
[docs]
def _text2settings(self, color: ParsableManimColor) -> list[TextSetting]:
"""Converts the texts and styles to a setting for parsing."""
t2xs: list[tuple[dict[str, str], str]] = [
(self.t2f, "font"),
(self.t2s, "slant"),
(self.t2w, "weight"),
(self.t2c, "color"),
]
# setting_args requires values to be strings
default_args: dict[str, Any] = {
arg: getattr(self, arg) if arg != "color" else color for _, arg in t2xs
}
settings = self._get_settings_from_t2xs(t2xs, default_args)
settings.extend(self._get_settings_from_gradient(default_args))
# Handle overlaps
settings.sort(key=lambda setting: setting.start)
for index, setting in enumerate(settings):
if index + 1 == len(settings):
break
next_setting = settings[index + 1]
if setting.end > next_setting.start:
new_setting = self._merge_settings(setting, next_setting, default_args)
new_index = index + 1
while (
new_index < len(settings)
and settings[new_index].start < new_setting.start
):
new_index += 1
settings.insert(new_index, new_setting)
# Set all text settings (default font, slant, weight)
temp_settings = settings.copy()
start = 0
for setting in settings:
if setting.start != start:
temp_settings.append(TextSetting(start, setting.start, **default_args))
start = setting.end
if start != len(self.text):
temp_settings.append(TextSetting(start, len(self.text), **default_args))
settings = sorted(temp_settings, key=lambda setting: setting.start)
line_num = 0
if re.search(r"\n", self.text):
for for_start, for_end in self._find_indexes("\n", self.text):
for setting in settings:
if setting.line_num == -1:
setting.line_num = line_num
if for_start < setting.end:
line_num += 1
new_setting = copy.copy(setting)
setting.end = for_end
new_setting.start = for_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 = line_num
return settings
[docs]
def _text2svg(self, color: ParsableManimColor) -> str:
"""Convert the text to SVG using Pango."""
size = self._font_size
line_spacing = self.line_spacing
size /= TEXT2SVG_ADJUSTMENT_FACTOR
line_spacing /= TEXT2SVG_ADJUSTMENT_FACTOR
dir_name = config.get_dir("text_dir")
dir_name.mkdir(parents=True, exist_ok=True)
hash_name = self._text2hash(color)
file_name = dir_name / (hash_name + ".svg")
if file_name.exists():
svg_file = str(file_name.resolve())
else:
settings = self._text2settings(color)
width = config["pixel_width"]
height = config["pixel_height"]
svg_file = manimpango.text2svg(
settings,
size,
line_spacing,
self.disable_ligatures,
str(file_name.resolve()),
START_X,
START_Y,
width,
height,
self.text,
)
return svg_file
[docs]
def init_colors(self, propagate_colors: bool = True) -> Self:
if config.renderer == RendererType.OPENGL:
super().init_colors()
elif config.renderer == RendererType.CAIRO:
super().init_colors(propagate_colors=propagate_colors)
return self
[docs]
class MarkupText(SVGMobject):
r"""Display (non-LaTeX) text rendered using `Pango <https://pango.org/>`_.
Text objects behave like a :class:`.VGroup`-like iterable of all characters
in the given text. In particular, slicing is possible.
**What is PangoMarkup?**
PangoMarkup is a small markup language like html and it helps you avoid using
"range of characters" while coloring or styling a piece a Text. You can use
this language with :class:`~.MarkupText`.
A simple example of a marked-up string might be::
<span foreground="blue" size="x-large">Blue text</span> is <i>cool</i>!"
and it can be used with :class:`~.MarkupText` as
.. manim:: MarkupExample
:save_last_frame:
class MarkupExample(Scene):
def construct(self):
text = MarkupText('<span foreground="blue" size="x-large">Blue text</span> is <i>cool</i>!"')
self.add(text)
A more elaborate example would be:
.. manim:: MarkupElaborateExample
:save_last_frame:
class MarkupElaborateExample(Scene):
def construct(self):
text = MarkupText(
'<span foreground="purple">ا</span><span foreground="red">َ</span>'
'ل<span foreground="blue">ْ</span>ع<span foreground="red">َ</span>ر'
'<span foreground="red">َ</span>ب<span foreground="red">ِ</span>ي'
'<span foreground="green">ّ</span><span foreground="red">َ</span>ة'
'<span foreground="blue">ُ</span>'
)
self.add(text)
PangoMarkup can also contain XML features such as numeric character
entities such as ``©`` for © can be used too.
The most general markup tag is ``<span>``, then there are some
convenience tags.
Here is a list of supported tags:
- ``<b>bold</b>``, ``<i>italic</i>`` and ``<b><i>bold+italic</i></b>``
- ``<u>underline</u>`` and ``<s>strike through</s>``
- ``<tt>typewriter font</tt>``
- ``<big>bigger font</big>`` and ``<small>smaller font</small>``
- ``<sup>superscript</sup>`` and ``<sub>subscript</sub>``
- ``<span underline="double" underline_color="green">double underline</span>``
- ``<span underline="error">error underline</span>``
- ``<span overline="single" overline_color="green">overline</span>``
- ``<span strikethrough="true" strikethrough_color="red">strikethrough</span>``
- ``<span font_family="sans">temporary change of font</span>``
- ``<span foreground="red">temporary change of color</span>``
- ``<span fgcolor="red">temporary change of color</span>``
- ``<gradient from="YELLOW" to="RED">temporary gradient</gradient>``
For ``<span>`` markup, colors can be specified either as
hex triples like ``#aabbcc`` or as named CSS colors like
``AliceBlue``.
The ``<gradient>`` tag is handled by Manim rather than
Pango, and supports hex triplets or Manim constants like
``RED`` or ``RED_A``.
If you want to use Manim constants like ``RED_A`` together
with ``<span>``, you will need to use Python's f-String
syntax as follows::
MarkupText(f'<span foreground="{RED_A}">here you go</span>')
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 ``fl`` are two separate characters,
but might be set as one single glyph - a ligature. If your language
does not depend on ligatures, consider setting ``disable_ligatures``
to ``True``. If you must use ligatures, the ``gradient`` tag supports an optional
attribute ``offset`` which can be used to compensate for that error.
For example:
- ``<gradient from="RED" to="YELLOW" offset="1">example</gradient>`` to *start* the gradient one letter earlier
- ``<gradient from="RED" to="YELLOW" offset=",1">example</gradient>`` to *end* the gradient one letter earlier
- ``<gradient from="RED" to="YELLOW" offset="2,1">example</gradient>`` 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
``<gradient>`` tags, you will also need to use the offset, because
underlines are additional paths in the final :class:`SVGMobject`.
Check out the following 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 <https://docs.gtk.org/Pango/pango_markup.html>`_.
Please be aware that not all features are supported by this class and that
the ``<gradient>`` tag mentioned above is not supported by Pango.
Parameters
----------
text
The text that needs to be created as mobject.
fill_opacity
The fill opacity, with 1 meaning opaque and 0 meaning transparent.
stroke_width
Stroke width.
font_size
Font size.
line_spacing
Line spacing.
font
Global font setting for the entire text. Local overrides are possible.
slant
Global slant setting, e.g. `NORMAL` or `ITALIC`. Local overrides are possible.
weight
Global weight setting, e.g. `NORMAL` or `BOLD`. Local overrides are possible.
gradient
Global gradient setting. Local overrides are possible.
warn_missing_font
If True (default), Manim will issue a warning if the font does not exist in the
(case-sensitive) list of fonts returned from `manimpango.list_fonts()`.
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("<b>foo</b> <i>bar</i> <b><i>foobar</i></b>")
text2 = MarkupText("<s>foo</s> <u>bar</u> <big>big</big> <small>small</small>")
text3 = MarkupText("H<sub>2</sub>O and H<sub>3</sub>O<sup>+</sup>")
text4 = MarkupText("type <tt>help</tt> for help")
text5 = MarkupText(
'<span underline="double">foo</span> <span underline="error">bar</span>'
)
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 <span fgcolor="{YELLOW}">except this</span>', color=RED
)
text2 = MarkupText("nice gradient", gradient=(BLUE, GREEN))
text3 = MarkupText(
'nice <gradient from="RED" to="YELLOW">intermediate</gradient> gradient',
gradient=(BLUE, GREEN),
)
text4 = MarkupText(
'fl ligature <gradient from="RED" to="YELLOW">causing trouble</gradient> here'
)
text5 = MarkupText(
'fl ligature <gradient from="RED" to="YELLOW" offset="1">defeated</gradient> with offset'
)
text6 = MarkupText(
'fl ligature <gradient from="RED" to="YELLOW" offset="1">floating</gradient> inside'
)
text7 = MarkupText(
'fl ligature <gradient from="RED" to="YELLOW" offset="1,1">floating</gradient> 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(
'<span underline="double" underline_color="green">bla</span>'
)
text2 = MarkupText(
'<span underline="single" underline_color="green">xxx</span><gradient from="#ffff00" to="RED">aabb</gradient>y'
)
text3 = MarkupText(
'<span underline="single" underline_color="green">xxx</span><gradient from="#ffff00" to="RED" offset="-1">aabb</gradient>y'
)
text4 = MarkupText(
'<span underline="double" underline_color="green">xxx</span><gradient from="#ffff00" to="RED">aabb</gradient>y'
)
text5 = MarkupText(
'<span underline="double" underline_color="green">xxx</span><gradient from="#ffff00" to="RED" offset="-2">aabb</gradient>y'
)
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 <span font_family="serif">except this</span>', font="sans"
)
text2 = MarkupText(
'<span font_family="serif">mixing</span> <span font_family="sans">fonts</span> <span font_family="monospace">is ugly</span>'
)
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('foooo<span foreground="red">oo\nbaa</span>aar')
self.add(text)
.. manim:: NoLigaturesExample
:save_last_frame:
class NoLigaturesExample(Scene):
def construct(self):
text1 = MarkupText('fl<gradient from="RED" to="GREEN">oat</gradient>ing')
text2 = MarkupText('fl<gradient from="RED" to="GREEN">oat</gradient>ing', 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")
japanese = MarkupText(
'<span fgcolor="blue">日本</span>へようこそ'
) # works as in ``Text``.
mess = MarkupText("Multi-Language", weight=BOLD)
russ = MarkupText("Здравствуйте मस नम म ", font="sans-serif")
hin = MarkupText("नमस्ते", font="sans-serif")
chinese = MarkupText("臂猿「黛比」帶著孩子", font="sans-serif")
group = VGroup(morning, japanese, mess, russ, hin, chinese).arrange(DOWN)
self.add(group)
You can justify the text by passing :attr:`justify` parameter.
.. manim:: JustifyText
class JustifyText(Scene):
def construct(self):
ipsum_text = (
"Lorem ipsum dolor sit amet, consectetur adipiscing elit."
"Praesent feugiat metus sit amet iaculis pulvinar. Nulla posuere "
"quam a ex aliquam, eleifend consectetur tellus viverra. Aliquam "
"fermentum interdum justo, nec rutrum elit pretium ac. Nam quis "
"leo pulvinar, dignissim est at, venenatis nisi."
)
justified_text = MarkupText(ipsum_text, justify=True).scale(0.4)
not_justified_text = MarkupText(ipsum_text, justify=False).scale(0.4)
just_title = Title("Justified")
njust_title = Title("Not Justified")
self.add(njust_title, not_justified_text)
self.play(
FadeOut(not_justified_text),
FadeIn(justified_text),
FadeOut(njust_title),
FadeIn(just_title),
)
self.wait(1)
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.')
"""
@staticmethod
@functools.cache
def font_list() -> list[str]:
value: list[str] = manimpango.list_fonts()
return value
def __init__(
self,
text: str,
fill_opacity: float = 1,
stroke_width: float = 0,
color: ParsableManimColor | None = None,
font_size: float = DEFAULT_FONT_SIZE,
line_spacing: float = -1,
font: str = "",
slant: str = NORMAL,
weight: str = NORMAL,
justify: bool = False,
gradient: Iterable[ParsableManimColor] | None = None,
tab_width: int = 4,
height: int | None = None,
width: int | None = None,
should_center: bool = True,
disable_ligatures: bool = False,
warn_missing_font: bool = True,
**kwargs: Any,
):
self.text = text
self.line_spacing: float = line_spacing
if font and warn_missing_font:
fonts_list = Text.font_list()
# handle special case of sans/sans-serif
if font.lower() == "sans-serif":
font = "sans"
if font not in fonts_list:
# check if the capitalized version is in the supported fonts
if font.capitalize() in fonts_list:
font = font.capitalize()
elif font.lower() in fonts_list:
font = font.lower()
elif font.title() in fonts_list:
font = font.title()
else:
logger.warning(f"Font {font} not in {fonts_list}.")
self.font = font
self._font_size = float(font_size)
self.slant = slant
self.weight = weight
self.gradient = gradient
self.tab_width = tab_width
self.justify = justify
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 <color> tags in MarkupText is deprecated. Please use <span foreground="..."> instead.',
)
gradientmap = self._extract_gradient_tags()
validate_error = MarkupUtils.validate(self.text)
if validate_error:
raise ValueError(validate_error)
if self.line_spacing == -1:
self.line_spacing = (
self._font_size + self._font_size * DEFAULT_LINE_SPACING_SCALE
)
else:
self.line_spacing = self._font_size + self._font_size * self.line_spacing
parsed_color: ManimColor = ManimColor(color) if color else VMobject().color
file_name = self._text2svg(parsed_color)
PangoUtils.remove_last_M(file_name)
super().__init__(
file_name,
fill_opacity=fill_opacity,
stroke_width=stroke_width,
height=height,
width=width,
should_center=should_center,
**kwargs,
)
self.chars = self.get_group_class()(*self.submobjects)
self.text = text_without_tabs.replace(" ", "").replace("\n", "")
nppc = self.n_points_per_curve
for each in self:
if len(each.points) == 0:
continue
points = each.points
curve_start = points[0]
assert len(curve_start) == self.dim, curve_start
# Some of the glyphs in this text might not be closed,
# so we close them by identifying when one curve ends
# but it is not where the next curve starts.
# It is more efficient to temporarily create a list
# of points and add them one at a time, then turn them
# into a numpy array at the end, rather than creating
# new numpy arrays every time a point or fixing line
# is added (which is O(n^2) for numpy arrays).
closed_curve_points: list[Point3D] = []
# OpenGL has points be part of quadratic Bezier curves;
# Cairo uses cubic Bezier curves.
if nppc == 3: # RendererType.OPENGL
def add_line_to(end: Point3D) -> None:
nonlocal closed_curve_points
start = closed_curve_points[-1]
closed_curve_points += [
start,
(start + end) / 2,
end,
]
else: # RendererType.CAIRO
def add_line_to(end: Point3D) -> None:
nonlocal closed_curve_points
start = closed_curve_points[-1]
closed_curve_points += [
start,
(start + start + end) / 3,
(start + end + end) / 3,
end,
]
for index, point in enumerate(points):
closed_curve_points.append(point)
if (
index != len(points) - 1
and (index + 1) % nppc == 0
and any(point != points[index + 1])
):
# Add straight line from last point on this curve to the
# start point on the next curve.
add_line_to(curve_start)
curve_start = points[index + 1]
# Make sure last curve is closed
add_line_to(curve_start)
each.points = np.array(closed_curve_points, ndmin=2)
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)
self.initial_height = self.height
@property
def font_size(self) -> float:
return (
self.height
/ self.initial_height
/ TEXT_MOB_SCALE_FACTOR
* 2.4
* self._font_size
/ DEFAULT_FONT_SIZE
)
@font_size.setter
def font_size(self, font_val: float) -> None:
# TODO: use pango's font size scaling.
if font_val <= 0:
raise ValueError("font_size must be greater than 0.")
else:
self.scale(font_val / self.font_size)
[docs]
def _text2hash(self, color: ParsableManimColor) -> str:
"""Generates ``sha256`` hash for file name."""
settings = (
"MARKUPPANGO"
+ self.font
+ self.slant
+ self.weight
+ ManimColor(color).to_hex().lower()
) # to differentiate from classical Pango Text
settings += str(self.line_spacing) + 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]
[docs]
def _text2svg(self, color: ParsableManimColor | None) -> str:
"""Convert the text to SVG using Pango."""
color = ManimColor(color)
size = self._font_size
line_spacing: float = self.line_spacing
size /= TEXT2SVG_ADJUSTMENT_FACTOR
line_spacing /= TEXT2SVG_ADJUSTMENT_FACTOR
dir_name = config.get_dir("text_dir")
dir_name.mkdir(parents=True, exist_ok=True)
hash_name = self._text2hash(color)
file_name = dir_name / (hash_name + ".svg")
if file_name.exists():
svg_file: str = str(file_name.resolve())
else:
final_text = (
f'<span foreground="{color.to_hex()}">{self.text}</span>'
if color is not None
else self.text
)
logger.debug(f"Setting Text {self.text}")
svg_file = MarkupUtils.text2svg(
final_text,
self.font,
self.slant,
self.weight,
size,
line_spacing,
self.disable_ligatures,
str(file_name.resolve()),
START_X,
START_Y,
600, # width
400, # height
justify=self.justify,
pango_width=500,
)
return svg_file
[docs]
def _count_real_chars(self, s: str) -> int:
"""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
[docs]
def _parse_color(self, col: str) -> str:
"""Parse color given in ``<color>`` or ``<gradient>`` tags."""
if re.match("#[0-9a-f]{6}", col):
return col
else:
return ManimColor(col).to_hex()
def __repr__(self) -> str:
return f"MarkupText({repr(self.original_text)})"
[docs]
@contextmanager
def register_font(font_file: str | Path) -> Iterator[None]:
"""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.
.. 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}. Checked paths: {possible_paths}"
raise FileNotFoundError(error)
try:
assert manimpango.register_font(str(file_path))
yield
finally:
manimpango.unregister_font(str(file_path))