# -*- coding: utf-8 -*-
"""
This module implements the classes used to show plots.
.. :copyright: (c) 2020 by Matthew Richards.
:license: MIT, see License for more details.
"""
from .base_classes import LatexObject, Command
import re
from .common import TikzLibrary, TikzObject, TikzAnchor
from .positions import (TikzRectCoord, BaseTikzCoord, TikzNode,
)
import warnings
def _warning(message, category, filename, lineno, file=None, line=None):
# note coverage excluded since this is never directly called since it is
# a monkey patch
name = category.__name__ if category else None # pragma: no cover
return f"{name} {message}" # pragma: no cover
warnings.formatwarning = _warning
[docs]class TikzUserPath(LatexObject):
"""Represents a possible TikZ path."""
def __init__(self, path_type, options=None):
"""
Args
----
path_type: str
Type of path used
options: Options
List of options to add
"""
super(TikzUserPath, self).__init__()
self.path_type = path_type
self.options = options
def dumps(self):
"""Return path command representation."""
ret_str = self.path_type
if self.options is not None:
ret_str += self.options.dumps()
return ret_str
[docs]class TikzRadius(LatexObject):
"""Class which represents specification of a radius or radii for use with
the 'circle' path argument. Should not need to be used directly, should
be able inference from context.
"""
def __init__(self, radius, ellipse_semi_minor_ax=None):
"""Initialise a Radius object for a circle or ellipse respectively"""
if isinstance(radius, (float, int)) is False:
raise TypeError("Radius must be an integer or float.")
if radius < 0:
raise ValueError(f"{__class__} radius cannot be negative.")
self._radius = radius
# if ellipse or not
if ellipse_semi_minor_ax is None:
self.is_ellipse = False
self._ellipse_semi_minor_ax = None
else:
self.is_ellipse = True
self._ellipse_semi_minor_ax = ellipse_semi_minor_ax
# continue type checking
if isinstance(ellipse_semi_minor_ax, (float, int)) is False:
raise TypeError("Semi-minor axis must be an integer or "
"float.")
if ellipse_semi_minor_ax < 0:
raise ValueError(f"{__class__} semi-minor axis cannot be "
f"negative.")
def dumps(self):
"""Return a string representation of a radius argument."""
if self.is_ellipse:
return "[x radius={}, y radius={}]".format(
self._radius, self._ellipse_semi_minor_ax)
else:
return "[radius={}]".format(self._radius)
[docs]class TikzPathList(LatexObject):
"""Represents a path drawing."""
_base_legal_path_types = ['--', '-|', '|-', 'to',
'rectangle', 'circle', 'ellipse',
'arc', 'edge']
def __init__(self, *args, additional_path_types=None):
"""
Args
----
*args: list
A list of path elements
"""
self._last_item_type = None
self._arg_list = []
self._legal_path_types = self._base_legal_path_types
if additional_path_types is not None:
self._legal_path_types.extend(additional_path_types)
# parse list and verify legality
self._parse_arg_list(args)
def append(self, item):
"""Add a new element to the current path."""
self._parse_next_item(item)
def _parse_next_item(self, item):
# assume first item is a point
if self._last_item_type is None:
try:
self._add_point(item)
except (TypeError, ValueError):
# not a point, do something
raise TypeError(
'First element of path list must be a node identifier'
' or coordinate'
)
elif self._last_item_type in ('point', 'arc', 'circle', 'ellipse'):
if isinstance(item, TikzNode):
# Note that we drop the preceding backslash since that is
# not part of inline syntax. trailing ";" dropped as well
# since TikzPath will add this from its own dumps
self._arg_list.append(item.dumps()[1:-1])
return
# point after point is permitted, doesnt draw
try:
self._add_point(item)
warnings.warn('TikzPath contains no path '
'specifier between successive coordinates. '
f'"{self.dumps()}" is legal '
'TikZ but is unlikely to produce the desired '
'result.\n')
return
except (ValueError, TypeError):
# not a point, try path
pass
if isinstance(item, (str, TikzUserPath)):
# special cases need more information
if item in ('circle', 'ellipse', 'arc'):
self._add_path(item, append_text=item)
else:
self._add_path(item, append_text=None)
# block for dealing with all path types
elif 'path' in self._last_item_type:
if self._last_item_type == 'path.arc':
# only allow arc specifier after arc path
# note this will throw exceptions if incorrect
self._add_arc_spec(item)
return
if self._last_item_type == 'path.circle':
self._add_circle(item)
elif self._last_item_type == 'path.ellipse':
self._add_ellipse(item)
else: # ordinary path - last type == 'path'
# only point or cycle allowed after path
if isinstance(item, str) and item.strip() == 'cycle':
self._arg_list.append(item)
return
try:
self._add_point(item)
return
except (TypeError, ValueError):
raise ValueError('only a point descriptor or "cycle" can '
'come after a path descriptor, got {}'
.format(type(item)))
def _parse_arg_list(self, args):
for item in args:
self._parse_next_item(item)
def _add_path(self, path, append_text=None):
"""Attempt to add input argument as a path type specifier,
raises and appropriate exception if invalid.
"""
if isinstance(path, str):
if path in self._legal_path_types:
_path = TikzUserPath(path)
else:
raise ValueError('Illegal user path type: "{}"'.format(path))
elif isinstance(path, TikzUserPath):
_path = path
# add
self._arg_list.append(_path)
self._last_item_type = 'path'
# add additional info if needed
if append_text is not None:
self._last_item_type += "." + append_text
def _add_point(self, point):
if isinstance(point, str):
try:
_item = TikzRectCoord.from_str(point)
except ValueError:
raise ValueError('Illegal point string: "{}"'.format(point))
elif isinstance(point, BaseTikzCoord):
_item = point
elif isinstance(point, tuple):
_item = TikzRectCoord(*point)
elif isinstance(point, TikzNode):
_item = '({})'.format(point.handle)
elif isinstance(point, TikzAnchor):
_item = point.dumps()
else:
raise TypeError('Only str, tuple or Tikz positional '
'classes are allowed,'
' got: {}'.format(type(point)))
# add, finally
self._arg_list.append(_item)
self._last_item_type = 'point'
def _add_arc_spec(self, arc):
if isinstance(arc, str):
try:
_arc = TikzArc.from_str(arc)
except ValueError:
raise ValueError('Illegal arc string: "{}"'.format(arc))
elif isinstance(arc, TikzArc):
_arc = arc
elif isinstance(arc, tuple):
_arc = TikzArc(*arc)
else:
raise TypeError('Only str, tuple or TikzArc'
'arc allowed to follow arc specifier,'
' got: {}'.format(type(arc)))
# add, finally
self._arg_list.append(_arc)
self._last_item_type = 'arc'
def _add_circle(self, item):
"""If circle is false then ellipse"""
method_error_msg = TypeError("Circle radius must be of type float, "
"str which is castable to float, "
f"or TikzRadius. Got {type(item)}.")
if isinstance(item, str):
try:
_item = TikzRadius(float(item))
except (ValueError, TypeError):
raise method_error_msg
elif isinstance(item, (float, int)):
# note non-negative is check on initialisation
_item = TikzRadius(item)
elif isinstance(item, TikzRadius):
if item.is_ellipse:
raise ValueError("'circle' path cannot be succeeded by"
" ellipse specifier")
_item = item
else:
raise method_error_msg
self._arg_list.append(_item)
self._last_item_type = 'circle'
def _add_ellipse(self, item):
method_error_msg = TypeError(
"Ellipse args must be of type tuple [of length 2], "
"a string representation of a tuple or "
f"or TikzRadius. Got {type(item)}.")
if isinstance(item, str):
m = re.match(r'\('
r'\s*([0-9]+(\.[0-9]+)?)\s*,'
r'\s*([0-9]+(\.[0-9]+)?)\s*\)', item)
if m is None:
raise method_error_msg
_item = TikzRadius(float(m.group(1)), float(m.group(3)))
elif (isinstance(item, (list, tuple)) and len(item) == 2
and (isinstance(item[0], (float, int))
and (item[1], (float, int)))):
_item = TikzRadius(*item)
elif isinstance(item, TikzRadius):
if item.is_ellipse is False:
raise ValueError("'ellipse' path cannot be succeeded by"
" circle specifier.")
_item = item
else:
raise method_error_msg
self._arg_list.append(_item)
self._last_item_type = 'ellipse'
def dumps(self):
"""Return representation of the path command."""
ret_str = []
for item in self._arg_list:
if isinstance(item, str):
ret_str.append(item)
elif isinstance(item, LatexObject):
ret_str.append(item.dumps())
return ' '.join(ret_str)
[docs]class TikzPath(TikzObject):
r"""The TikZ \path command."""
def __init__(self, path=None, options=None):
"""
Args
----
path: TikzPathList or list
A list of the nodes, path types in the path
options: TikzOptions
A list of options for the command
"""
super(TikzPath, self).__init__(options=options)
additional_path_types = None
if options is not None and 'use Hobby shortcut' in options:
self.packages.add(TikzLibrary('hobby'))
additional_path_types = [".."]
# if already a TikzPathList, additional paths should have already been
# supplied
if isinstance(path, TikzPathList):
self.path = path
elif isinstance(path, list):
self.path = TikzPathList(
*path, additional_path_types=additional_path_types)
elif path is None:
self.path = TikzPathList(
additional_path_types=additional_path_types)
else:
raise TypeError(
'argument "path" can only be of types list or TikzPathList')
def append(self, element):
"""Append a path element to the current list."""
self.path.append(element)
def dumps(self):
"""Return a representation for the command."""
ret_str = [Command('path', options=self.options).dumps()]
ret_str.append(self.path.dumps())
return ' '.join(ret_str) + ';'
[docs]class TikzDraw(TikzPath):
"""A draw command is just a path command with the draw option."""
def __init__(self, path=None, options=None):
"""
Args
----
path: `~.TikzPathList` or List
A list of the nodes, path types in the path
options: TikzOptions
A list of options for the command
"""
super(TikzDraw, self).__init__(path=path, options=options)
def dumps(self):
r"""Return a representation for the command. Override
to provide clearer syntax to user instead of \path[draw]
"""
ret_str = [Command('draw', options=self.options).dumps()]
ret_str.append(self.path.dumps())
return ' '.join(ret_str) + ";"
[docs]class TikzArc(LatexObject):
"""A class to represent the tikz specification for arcs
i.e. (ang1: ang2: rad)
"""
_str_verif_regex = re.compile(r'\('
r'\s*(-?[0-9]+(\.[0-9]+)?)\s*:'
r'\s*(-?[0-9]+(\.[0-9]+)?)\s*:'
r'\s*([0-9]+(\.[0-9]+)?)\s*\)')
def __init__(self, start_ang, finish_ang, radius,
force_far_direction=False):
"""
start_ang: float or int
angle in degrees
radius: float or int
radius from orig
force_far_direction: bool
forces arc to go in the longer direction around circumference
"""
if force_far_direction:
# forcing an extra rotation around
if start_ang > finish_ang:
start_ang -= 360
else:
finish_ang -= 360
self._radius = float(radius)
self._start_ang = float(start_ang)
self._finish_ang = float(finish_ang)
def __repr__(self):
return "({}:{}:{})".format(
self._start_ang, self._finish_ang, self._radius)
def dumps(self):
"""Return a representation. Alias for consistency."""
return self.__repr__()
@classmethod
[docs] def from_str(cls, arc):
"""Build a TikzArc object from a string."""
m = cls._str_verif_regex.match(arc)
if m is None:
raise ValueError('invalid arc string')
return cls(float(m.group(1)), float(m.group(3)), float(m.group(5)))