2019-08-12 14:40:42 +08:00
|
|
|
import hashlib
|
2021-02-12 12:28:08 +05:30
|
|
|
import os
|
|
|
|
import re
|
2021-06-20 01:08:17 +05:30
|
|
|
import io
|
2021-02-12 12:28:08 +05:30
|
|
|
import typing
|
2021-06-20 01:08:17 +05:30
|
|
|
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-06-20 01:08:17 +05:30
|
|
|
|
2021-02-12 12:28:08 +05:30
|
|
|
from contextlib import contextmanager
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
import manimpango
|
2022-01-26 19:55:47 +08:00
|
|
|
from manimlib.logger import log
|
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
|
2021-06-20 01:08:17 +05:30
|
|
|
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
|
2020-05-07 16:21:43 +08:00
|
|
|
|
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
|
2021-01-16 09:37:54 +08:00
|
|
|
"color": WHITE,
|
|
|
|
"height": None,
|
|
|
|
"stroke_width": 0,
|
2019-08-12 14:40:42 +08:00
|
|
|
# Text
|
2021-01-16 09:37:54 +08:00
|
|
|
"font": '',
|
|
|
|
"gradient": None,
|
|
|
|
"lsh": -1,
|
2021-06-18 17:59:15 +05:30
|
|
|
"size": None,
|
2021-01-16 09:37:54 +08:00
|
|
|
"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)
|
2021-06-18 17:59:15 +05:30
|
|
|
if self.size:
|
2022-01-26 19:55:47 +08:00
|
|
|
log.warning(
|
|
|
|
"`self.size` has been deprecated and will "
|
2021-06-18 17:59:15 +05:30
|
|
|
"be removed in future.",
|
|
|
|
)
|
|
|
|
self.font_size = self.size
|
|
|
|
if self.lsh == -1:
|
2021-06-19 00:43:01 +05:30
|
|
|
self.lsh = self.font_size + self.font_size * DEFAULT_LINE_SPACING_SCALE
|
2021-06-18 17:59:15 +05:30
|
|
|
else:
|
|
|
|
self.lsh = self.font_size + self.font_size * self.lsh
|
2021-01-16 09:35:13 +08:00
|
|
|
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)
|
2021-01-16 09:35:13 +08:00
|
|
|
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
|
2020-05-07 16:21:43 +08:00
|
|
|
if self.height is None:
|
2021-06-19 13:11:36 +08:00
|
|
|
self.scale(TEXT_MOB_SCALE_FACTOR)
|
2020-05-07 16:21:43 +08:00
|
|
|
|
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)
|
|
|
|
|
2021-01-16 09:35:13 +08:00
|
|
|
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)
|
2021-01-16 09:35:13 +08:00
|
|
|
|
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)
|
|
|
|
))
|
|
|
|
|
2021-03-19 10:55:04 -07:00
|
|
|
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)
|
2021-06-18 17:59:15 +05:30
|
|
|
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
|
2021-06-18 17:59:15 +05:30
|
|
|
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()
|
2021-06-18 17:59:15 +05:30
|
|
|
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
|
|
|
|
2021-06-20 01:08:17 +05:30
|
|
|
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
|
|
|
|
2021-06-20 01:08:17 +05:30
|
|
|
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
|
|
|
|
2021-06-20 01:08:17 +05:30
|
|
|
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 ::
|
2021-02-19 00:49:50 +05:30
|
|
|
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))
|