3b1b-manim/manimlib/mobject/svg/text_mobject.py

600 lines
21 KiB
Python
Raw Normal View History

2019-08-12 14:40:42 +08:00
import hashlib
2021-02-12 12:28:08 +05:30
import os
import re
import io
2021-02-12 12:28:08 +05:30
import typing
import warnings
import xml.etree.ElementTree as ET
import functools
2021-09-15 20:02:57 +08:00
import pygments
import pygments.lexers
import pygments.styles
2021-02-12 12:28:08 +05:30
from contextlib import contextmanager
from pathlib import Path
import manimpango
2019-08-05 22:53:15 +08:00
from manimlib.constants import *
2021-01-18 08:13:18 -10:00
from manimlib.mobject.geometry import Dot
2019-08-05 22:53:15 +08:00
from manimlib.mobject.svg.svg_mobject import SVGMobject
2021-03-18 17:34:57 -07:00
from manimlib.mobject.types.vectorized_mobject import VGroup
2019-08-12 09:35:05 +08:00
from manimlib.utils.config_ops import digest_config
2021-01-15 10:16:37 -10:00
from manimlib.utils.customization import get_customization
2021-02-12 12:28:08 +05:30
from manimlib.utils.directories import get_downloads_dir, get_text_dir
from manimpango import PangoUtils, TextSetting, MarkupUtils
2019-08-05 22:53:15 +08:00
2021-06-19 19:33:53 +08:00
TEXT_MOB_SCALE_FACTOR = 0.0076
2021-10-01 12:32:38 -07:00
DEFAULT_LINE_SPACING_SCALE = 0.6
2021-09-15 20:02:57 +08:00
2019-08-05 22:53:15 +08:00
class Text(SVGMobject):
CONFIG = {
2019-08-12 14:40:42 +08:00
# Mobject
"color": WHITE,
"height": None,
"stroke_width": 0,
2019-08-12 14:40:42 +08:00
# Text
"font": '',
"gradient": None,
"lsh": -1,
"size": None,
"font_size": 48,
"tab_width": 4,
"slant": NORMAL,
"weight": NORMAL,
"t2c": {},
"t2f": {},
"t2g": {},
"t2s": {},
"t2w": {},
2021-02-12 12:28:08 +05:30
"disable_ligatures": True,
2019-08-05 22:53:15 +08:00
}
2021-09-15 20:02:57 +08:00
def __init__(self, text, **kwargs):
self.full2short(kwargs)
digest_config(self, kwargs)
if self.size:
warnings.warn(
"self.size has been deprecated and will "
"be removed in future.",
DeprecationWarning
)
self.font_size = self.size
if self.lsh == -1:
self.lsh = self.font_size + self.font_size * DEFAULT_LINE_SPACING_SCALE
else:
self.lsh = self.font_size + self.font_size * self.lsh
text_without_tabs = text
if text.find('\t') != -1:
text_without_tabs = text.replace('\t', ' ' * self.tab_width)
self.text = text_without_tabs
2019-08-12 09:35:05 +08:00
file_name = self.text2svg()
2021-02-12 12:28:08 +05:30
PangoUtils.remove_last_M(file_name)
2021-02-13 17:51:43 +08:00
self.remove_empty_path(file_name)
2021-09-15 20:02:57 +08:00
SVGMobject.__init__(self, file_name, **kwargs)
self.text = text
2021-02-12 12:28:08 +05:30
if self.disable_ligatures:
self.apply_space_chars()
2019-08-12 09:35:05 +08:00
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()
2019-08-05 22:53:15 +08:00
2019-08-12 14:40:42 +08:00
# anti-aliasing
if self.height is None:
self.scale(TEXT_MOB_SCALE_FACTOR)
2021-02-13 17:51:43 +08:00
def remove_empty_path(self, file_name):
with open(file_name, 'r') as fpr:
content = fpr.read()
content = re.sub(r'<path .*?d=""/>', '', content)
with open(file_name, 'w') as fpw:
fpw.write(content)
def apply_space_chars(self):
2021-01-16 10:20:41 +08:00
submobs = self.submobjects.copy()
2021-02-12 12:28:08 +05:30
for char_index in range(len(self.text)):
2021-02-12 13:34:16 -08:00
if self.text[char_index] in [" ", "\t", "\n"]:
2021-01-16 10:20:41 +08:00
space = Dot(radius=0, fill_opacity=0, stroke_opacity=0)
2021-02-12 13:34:16 -08:00
space.move_to(submobs[max(char_index - 1, 0)].get_center())
2021-01-16 10:20:41 +08:00
submobs.insert(char_index, space)
self.set_submobjects(submobs)
2019-08-12 09:35:05 +08:00
def find_indexes(self, word):
m = re.match(r'\[([0-9\-]{0,}):([0-9\-]{0,})\]', word)
if m:
start = int(m.group(1)) if m.group(1) != '' else 0
end = int(m.group(2)) if m.group(2) != '' else len(self.text)
start = len(self.text) + start if start < 0 else start
end = len(self.text) + end if end < 0 else end
return [(start, end)]
2019-08-05 22:53:15 +08:00
indexes = []
2019-08-12 09:35:05 +08:00
index = self.text.find(word)
2019-08-05 22:53:15 +08:00
while index != -1:
2019-08-12 09:35:05 +08:00
indexes.append((index, index + len(word)))
index = self.text.find(word, index + len(word))
2019-08-05 22:53:15 +08:00
return indexes
2021-03-18 17:34:57 -07:00
def get_parts_by_text(self, word):
return VGroup(*(
self[i:j]
for i, j in self.find_indexes(word)
))
def get_part_by_text(self, word):
parts = self.get_parts_by_text(word)
2021-03-18 17:34:57 -07:00
if len(parts) > 0:
return parts[0]
else:
return None
2019-08-12 09:35:05 +08:00
def full2short(self, config):
for kwargs in [config, self.CONFIG]:
if kwargs.__contains__('line_spacing_height'):
kwargs['lsh'] = kwargs.pop('line_spacing_height')
if kwargs.__contains__('text2color'):
kwargs['t2c'] = kwargs.pop('text2color')
if kwargs.__contains__('text2font'):
kwargs['t2f'] = kwargs.pop('text2font')
if kwargs.__contains__('text2gradient'):
kwargs['t2g'] = kwargs.pop('text2gradient')
if kwargs.__contains__('text2slant'):
kwargs['t2s'] = kwargs.pop('text2slant')
if kwargs.__contains__('text2weight'):
kwargs['t2w'] = kwargs.pop('text2weight')
def set_color_by_t2c(self, t2c=None):
t2c = t2c if t2c else self.t2c
2021-09-15 20:02:57 +08:00
for word, color in t2c.items():
2019-08-12 09:35:05 +08:00
for start, end in self.find_indexes(word):
self[start:end].set_color(color)
2019-08-05 22:53:15 +08:00
2019-08-12 09:35:05 +08:00
def set_color_by_t2g(self, t2g=None):
t2g = t2g if t2g else self.t2g
2021-09-15 20:02:57 +08:00
for word, gradient in t2g.items():
2019-08-12 09:35:05 +08:00
for start, end in self.find_indexes(word):
self[start:end].set_color_by_gradient(*gradient)
2019-08-05 22:53:15 +08:00
2019-08-12 09:35:05 +08:00
def text2hash(self):
settings = self.font + self.slant + self.weight
settings += str(self.t2f) + str(self.t2s) + str(self.t2w)
settings += str(self.lsh) + str(self.font_size)
2021-01-15 10:16:37 -10:00
id_str = self.text + settings
2019-08-05 22:53:15 +08:00
hasher = hashlib.sha256()
hasher.update(id_str.encode())
return hasher.hexdigest()[:16]
2019-08-12 09:35:05 +08:00
def text2settings(self):
2021-09-15 20:02:57 +08:00
"""
Substrings specified in t2f, t2s, t2w can occupy each other.
For each category of style, a stack following first-in-last-out is constructed,
and the last value in each stack takes effect.
"""
2019-08-12 09:35:05 +08:00
settings = []
2021-09-15 20:02:57 +08:00
self.line_num = 0
def add_text_settings(start, end, style_stacks):
if start == end:
return
breakdown_indices = [start, *[
i + start + 1 for i, char in enumerate(self.text[start:end]) if char == "\n"
], end]
style = [stack[-1] for stack in style_stacks]
for atom_start, atom_end in zip(breakdown_indices[:-1], breakdown_indices[1:]):
if atom_start < atom_end:
settings.append(TextSetting(atom_start, atom_end, *style, self.line_num))
self.line_num += 1
self.line_num -= 1
# Set all the default and specified values.
len_text = len(self.text)
t2x_items = sorted([
*[
(0, len_text, t2x_index, value)
for t2x_index, value in enumerate([self.font, self.slant, self.weight])
],
*[
(start, end, t2x_index, value)
for t2x_index, t2x in enumerate([self.t2f, self.t2s, self.t2w])
for word, value in t2x.items()
for start, end in self.find_indexes(word)
]
], key=lambda item: item[0])
# Break down ranges and construct settings separately.
active_items = []
style_stacks = [[] for _ in range(3)]
for item, next_start in zip(t2x_items, [*[item[0] for item in t2x_items[1:]], len_text]):
active_items.append(item)
start, end, t2x_index, value = item
style_stacks[t2x_index].append(value)
halting_items = sorted(filter(
lambda item: item[1] <= next_start,
active_items
), key=lambda item: item[1])
atom_start = start
for halting_item in halting_items:
active_items.remove(halting_item)
_, atom_end, t2x_index, _ = halting_item
add_text_settings(atom_start, atom_end, style_stacks)
style_stacks[t2x_index].pop()
atom_start = atom_end
add_text_settings(atom_start, next_start, style_stacks)
del self.line_num
2019-08-12 09:35:05 +08:00
return settings
2019-08-05 22:53:15 +08:00
2019-08-12 09:35:05 +08:00
def text2svg(self):
2019-08-12 14:40:42 +08:00
# anti-aliasing
size = self.font_size
lsh = self.lsh
2019-08-05 22:53:15 +08:00
2019-08-12 09:35:05 +08:00
if self.font == '':
2021-01-15 10:16:37 -10:00
self.font = get_customization()['style']['font']
2019-08-05 22:53:15 +08:00
2021-01-15 20:18:13 +08:00
dir_name = get_text_dir()
2019-08-12 09:35:05 +08:00
hash_name = self.text2hash()
2021-01-15 10:16:37 -10:00
file_name = os.path.join(dir_name, hash_name) + '.svg'
2019-08-05 22:53:15 +08:00
if os.path.exists(file_name):
return file_name
2019-08-12 09:35:05 +08:00
settings = self.text2settings()
width = DEFAULT_PIXEL_WIDTH
height = DEFAULT_PIXEL_HEIGHT
2021-02-12 12:28:08 +05:30
disable_liga = self.disable_ligatures
return manimpango.text2svg(
settings,
size,
lsh,
disable_liga,
file_name,
START_X,
START_Y,
width,
height,
self.text,
)
2021-03-18 17:34:57 -07:00
class MarkupText(SVGMobject):
CONFIG = {
# Mobject
"color": WHITE,
"height": None,
# Text
"font": '',
"font_size": 48,
"lsh": None,
"justify": False,
"slant": NORMAL,
"weight": NORMAL,
"tab_width": 4,
"gradient": None,
"disable_ligatures": True,
}
2021-09-15 20:02:57 +08:00
def __init__(self, text, **config):
digest_config(self, config)
self.text = f'<span>{text}</span>'
self.original_text = self.text
self.text_for_parsing = self.text
text_without_tabs = text
if "\t" in text:
text_without_tabs = text.replace("\t", " " * self.tab_width)
try:
colormap = self.extract_color_tags()
gradientmap = self.extract_gradient_tags()
except ET.ParseError:
# let pango handle that error
pass
validate_error = MarkupUtils.validate(self.text)
if validate_error:
raise ValueError(validate_error)
file_name = self.text2svg()
PangoUtils.remove_last_M(file_name)
super().__init__(
file_name,
**config,
)
self.chars = self.get_group_class()(*self.submobjects)
self.text = text_without_tabs.replace(" ", "").replace("\n", "")
if self.gradient:
self.set_color_by_gradient(*self.gradient)
for col in colormap:
self.chars[
col["start"]
- col["start_offset"] : col["end"]
- col["start_offset"]
- col["end_offset"]
].set_color(self._parse_color(col["color"]))
for grad in gradientmap:
self.chars[
grad["start"]
- grad["start_offset"] : grad["end"]
- grad["start_offset"]
- grad["end_offset"]
].set_color_by_gradient(
*(self._parse_color(grad["from"]), self._parse_color(grad["to"]))
)
# anti-aliasing
if self.height is None:
self.scale(TEXT_MOB_SCALE_FACTOR)
2021-09-15 20:02:57 +08:00
def text2hash(self):
"""Generates ``sha256`` hash for file name."""
settings = (
"MARKUPPANGO" + self.font + self.slant + self.weight + self.color
) # to differentiate from classical Pango Text
settings += str(self.lsh) + str(self.font_size)
settings += str(self.disable_ligatures)
settings += str(self.justify)
id_str = self.text + settings
hasher = hashlib.sha256()
hasher.update(id_str.encode())
return hasher.hexdigest()[:16]
def text2svg(self):
"""Convert the text to SVG using Pango."""
size = self.font_size
dir_name = get_text_dir()
disable_liga = self.disable_ligatures
if not os.path.exists(dir_name):
os.makedirs(dir_name)
hash_name = self.text2hash()
file_name = os.path.join(dir_name, hash_name) + ".svg"
if os.path.exists(file_name):
return file_name
extra_kwargs = {}
extra_kwargs['justify'] = self.justify
extra_kwargs['pango_width'] = DEFAULT_PIXEL_WIDTH - 100
if self.lsh:
extra_kwargs['line_spacing']=self.lsh
return MarkupUtils.text2svg(
f'<span foreground="{self.color}">{self.text}</span>',
self.font,
self.slant,
self.weight,
size,
0, # empty parameter
disable_liga,
file_name,
START_X,
START_Y,
DEFAULT_PIXEL_WIDTH, # width
DEFAULT_PIXEL_HEIGHT, # height
**extra_kwargs
)
def _parse_color(self, col):
"""Parse color given in ``<color>`` or ``<gradient>`` tags."""
if re.match("#[0-9a-f]{6}", col):
return col
else:
return globals()[col.upper()] # this is hacky
@functools.lru_cache(10)
def get_text_from_markup(self, element=None):
if not element:
element = ET.fromstring(self.text_for_parsing)
final_text = ''
for i in element.itertext():
final_text += i
return final_text
def extract_color_tags(self, text=None, colormap = None):
"""Used to determine which parts (if any) of the string should be formatted
with a custom color.
Removes the ``<color>`` tag, as it is not part of Pango's markup and would cause an error.
Note: Using the ``<color>`` tags is deprecated. As soon as the legacy syntax is gone, this function
will be removed.
"""
if not text:
text = self.text_for_parsing
if not colormap:
colormap = list()
elements = ET.fromstring(text)
text_from_markup = self.get_text_from_markup()
final_xml = ET.fromstring(f'<span>{elements.text if elements.text else ""}</span>')
def get_color_map(elements):
for element in elements:
if element.tag == 'color':
element_text = self.get_text_from_markup(element)
start = text_from_markup.find(element_text)
end = start + len(element_text)
offsets = element.get('offset').split(",") if element.get('offset') else [0]
start_offset = int(offsets[0]) if offsets[0] else 0
end_offset = int(offsets[1]) if len(offsets) == 2 and offsets[1] else 0
colormap.append(
{
"start": start,
"end": end,
"color": element.get('col'),
"start_offset": start_offset,
"end_offset": end_offset,
}
)
_elements_list = list(element.iter())
if len(_elements_list) <= 1:
final_xml.append(ET.fromstring(f'<span>{element.text if element.text else ""}</span>'))
else:
final_xml.append(_elements_list[-1])
else:
if len(list(element.iter())) == 1:
final_xml.append(element)
else:
get_color_map(element)
get_color_map(elements)
with io.BytesIO() as f:
tree = ET.ElementTree()
tree._setroot(final_xml)
tree.write(f)
self.text = f.getvalue().decode()
self.text_for_parsing = self.text # gradients will use it
return colormap
def extract_gradient_tags(self, text=None,gradientmap=None):
"""Used to determine which parts (if any) of the string should be formatted
with a gradient.
Removes the ``<gradient>`` tag, as it is not part of Pango's markup and would cause an error.
"""
if not text:
text = self.text_for_parsing
if not gradientmap:
gradientmap = list()
elements = ET.fromstring(text)
text_from_markup = self.get_text_from_markup()
final_xml = ET.fromstring(f'<span>{elements.text if elements.text else ""}</span>')
def get_gradient_map(elements):
for element in elements:
if element.tag == 'gradient':
element_text = self.get_text_from_markup(element)
start = text_from_markup.find(element_text)
end = start + len(element_text)
offsets = element.get('offset').split(",") if element.get('offset') else [0]
start_offset = int(offsets[0]) if offsets[0] else 0
end_offset = int(offsets[1]) if len(offsets) == 2 and offsets[1] else 0
gradientmap.append(
{
"start": start,
"end": end,
"from": element.get('from'),
"to": element.get('to'),
"start_offset": start_offset,
"end_offset": end_offset,
}
)
_elements_list = list(element.iter())
if len(_elements_list) == 1:
final_xml.append(ET.fromstring(f'<span>{element.text if element.text else ""}</span>'))
else:
final_xml.append(_elements_list[-1])
else:
if len(list(element.iter())) == 1:
final_xml.append(element)
else:
get_gradient_map(element)
get_gradient_map(elements)
with io.BytesIO() as f:
tree = ET.ElementTree()
tree._setroot(final_xml)
tree.write(f)
self.text = f.getvalue().decode()
return gradientmap
def __repr__(self):
return f"MarkupText({repr(self.original_text)})"
2021-09-15 20:02:57 +08:00
class Code(Text):
CONFIG = {
"font": "Consolas",
"font_size": 24,
"lsh": 1.0,
"language": "python",
# Visit https://pygments.org/demo/ to have a preview of more styles.
"code_style": "monokai",
# If not None, then each character will cover a space of equal width.
"char_width": None
}
def __init__(self, code, **kwargs):
self.full2short(kwargs)
digest_config(self, kwargs)
code = code.lstrip("\n") # avoid mismatches of character indices
lexer = pygments.lexers.get_lexer_by_name(self.language)
tokens_generator = pygments.lex(code, lexer)
styles_dict = dict(pygments.styles.get_style_by_name(self.code_style))
default_color_hex = styles_dict[pygments.token.Text]["color"]
if not default_color_hex:
default_color_hex = self.color[1:]
start_index = 0
t2c = {}
t2s = {}
t2w = {}
for pair in tokens_generator:
ttype, token = pair
end_index = start_index + len(token)
range_str = f"[{start_index}:{end_index}]"
style_dict = styles_dict[ttype]
t2c[range_str] = "#" + (style_dict["color"] or default_color_hex)
t2s[range_str] = ITALIC if style_dict["italic"] else NORMAL
t2w[range_str] = BOLD if style_dict["bold"] else NORMAL
start_index = end_index
t2c.update(self.t2c)
t2s.update(self.t2s)
t2w.update(self.t2w)
kwargs["t2c"] = t2c
kwargs["t2s"] = t2s
kwargs["t2w"] = t2w
Text.__init__(self, code, **kwargs)
if self.char_width is not None:
self.set_monospace(self.char_width)
def set_monospace(self, char_width):
current_char_index = 0
for i, char in enumerate(self.text):
if char == "\n":
current_char_index = 0
continue
self[i].set_x(current_char_index * char_width)
current_char_index += 1
self.center()
2021-02-12 12:28:08 +05:30
@contextmanager
def register_font(font_file: typing.Union[str, Path]):
"""Temporarily add a font file to Pango's search path.
This searches for the font_file at various places. The order it searches it described below.
1. Absolute path.
2. Downloads dir.
Parameters
----------
font_file :
The font file to add.
Examples
--------
Use ``with register_font(...)`` to add a font file to search
path.
.. code-block:: python
with register_font("path/to/font_file.ttf"):
a = Text("Hello", font="Custom Font Name")
Raises
------
FileNotFoundError:
If the font doesn't exists.
AttributeError:
If this method is used on macOS.
Notes
-----
This method of adding font files also works with :class:`CairoText`.
.. important ::
This method is available for macOS for ``ManimPango>=v0.2.3``. Using this
method with previous releases will raise an :class:`AttributeError` on macOS.
2021-02-12 12:28:08 +05:30
"""
input_folder = Path(get_downloads_dir()).parent.resolve()
possible_paths = [
Path(font_file),
input_folder / font_file,
]
for path in possible_paths:
path = path.resolve()
if path.exists():
file_path = path
break
else:
error = f"Can't find {font_file}." f"Tried these : {possible_paths}"
raise FileNotFoundError(error)
try:
assert manimpango.register_font(str(file_path))
yield
finally:
manimpango.unregister_font(str(file_path))