# -*- coding: utf-8 -*-
"""
This module the TikZ classes which keep track of positions, namely
coordinates and nodes
.. :copyright: (c) 2020 by Matthew Richards.
:license: MIT, see License for more details.
"""
from abc import ABC
from .base_classes import Command
import re
import math
from .common import TikzLibrary, TikzObject
from .base_classes import LatexObject
[docs]class TikzNode(TikzObject):
"""A class that represents a TiKZ node."""
_possible_anchors = ['north', 'south', 'east', 'west']
def __init__(self, handle=None, options=None, at=None, text=None):
"""
Args
----
handle: str
Node identifier
options: list or `~.TikzOptions`
List of options
at: TikzRectCoord
Coordinate where node is placed
text: str
Body text of the node
"""
super(TikzNode, self).__init__(options=options)
self.handle = handle
if isinstance(at, (TikzRectCoord, type(None))):
self._node_position = at
else:
raise TypeError(
'at parameter must be an object of the'
'TikzCoordinate class')
self._node_text = text
[docs]class BaseTikzCoord(LatexObject, ABC):
"""Marker abstract class from which all coordinate classes inherit. Allows
for cleaner use of isinstance regarding all coordinate objects.
Note this intentionally breaks the naming convention of Tikz<**>Coord
as it not to be used.
This should be a private class, but sphinx throws a reference target not
found error if it is.
This should be an abstract class with ABC but could not implement this in
a python 2/3 friendly way that also worked with the 3to2 conversion.
"""
[docs]class TikzRectCoord(BaseTikzCoord):
r"""Extension of `~.BaseTikzCoord`. Forms a General Purpose
Coordinate Class, representing a tuple of points specified, as opposed
to the node shortcut command \coordinate.
"""
_coordinate_str_regex = re.compile(r'(\+\+)?\(\s*(-?[0-9]+(\.[0-9]+)?)\s*'
r',\s*(-?[0-9]+(\.[0-9]+)?)\s*\)')
def __init__(self, x, y, relative=False):
"""
Args
----
x: float or int
X coordinate
y: float or int
Y coordinate
relative: bool
Coordinate is relative or absolute
"""
self._x = float(x)
self._y = float(y)
self.relative = relative
self.to_stop = False
def __repr__(self):
if self.relative:
ret_str = '++'
else:
ret_str = ''
return ret_str + '({},{})'.format(round(self._x, 3), round(self._y, 3))
def dumps(self):
"""Return representation."""
return self.__repr__()
def __iter__(self):
return iter((self._x, self._y))
@classmethod
[docs] def from_str(cls, coordinate):
"""Build a TikzCoordinate object from a string."""
m = cls._coordinate_str_regex.match(coordinate)
if m is None:
raise ValueError('invalid coordinate string')
if m.group(1) == '++':
relative = True
else:
relative = False
return cls(
float(m.group(2)), float(m.group(4)), relative=relative)
def __eq__(self, other):
if isinstance(other, tuple):
# if comparing to a tuple, assume it to be an absolute coordinate.
other_relative = False
other_x = float(other[0])
other_y = float(other[1])
elif isinstance(other, TikzRectCoord):
other_relative = other.relative
other_x = other._x
other_y = other._y
else:
raise TypeError('can only compare tuple and TikzRectCoord types')
# prevent comparison between relative and non relative
# by returning False
if other_relative != self.relative:
return False
tol = 1e-6
# return comparison result
return abs(other_x - self._x) < tol and abs(other_y - self._y) < tol
def _arith_check(self, other):
if isinstance(other, tuple):
other_coord = TikzRectCoord(*other)
elif isinstance(other, TikzRectCoord):
if other.relative is True or self.relative is True:
raise ValueError('refusing to add relative coordinates')
other_coord = other
elif isinstance(other, BaseTikzCoord):
return False
else:
raise TypeError('can only add tuple or TiKZCoordinate types')
return other_coord
def __add__(self, other):
other_coord = self._arith_check(other)
# we have a legal type but can't use other coord syntax
# hope that operation is implemented in reverse
if other_coord is False:
return other + self
return TikzRectCoord(self._x + other_coord._x,
self._y + other_coord._y)
def __radd__(self, other):
return self.__add__(other)
def __sub__(self, second, first=None):
"""First - second, optional arg for rsubs"""
first = self if first is None else first
second_coord = self._arith_check(second)
if second_coord is False:
return second.__rsub__(first)
return TikzRectCoord(first._x - second_coord._x,
first._y - second_coord._y)
def __rsub__(self, other):
other = self._arith_check(other)
# note that other should never return the exception flag False
# as every class which extends BaseTikzCoord should also
# support subtraction with rectangular coords. If not, the defautlt
# exception should suffice
return self.__sub__(first=other, second=self)
[docs] def distance_to(self, other):
"""Euclidean distance between two coordinates."""
other_coord = self._arith_check(other)
return math.sqrt(math.pow(self._x - other_coord._x, 2)
+ math.pow(self._y - other_coord._y, 2))
[docs]class TikzPolCoord(TikzRectCoord):
"""Class representing the Tikz polar coordinate specification"""
_coordinate_str_regex = re.compile(r'(\+\+)?\(\s*(-?[0-9]+(\.[0-9]+)?)\s*'
r':\s*([0-9]+(\.[0-9]+)?)\s*\)')
def __init__(self, angle, radius, relative=False):
"""
angle: float or int
angle in degrees
radius: float or int, non-negative
radius from orig
relative: bool
Coordinate is relative or absolute
"""
if radius < 0:
raise ValueError("Radius must be positive")
self._radius = float(radius)
self._angle = float(angle)
x = radius * math.cos(math.radians(angle))
y = radius * math.sin(math.radians(angle))
super(TikzPolCoord, self).__init__(x, y, relative=relative)
def __repr__(self):
if self.relative:
ret_str = '++'
else:
ret_str = ''
return ret_str + '({}:{})'.format(self._angle, self._radius)
class _TikzCalcCoordHandle(BaseTikzCoord):
r"""Class to represent the syntax of using coordinate handle defined with
\coordinate as opposed to defining the coordinate.
Perhaps this can avoid being a seperate class, but the clear solution
would be to make init return a tuple, - the comand defn reference and
the handle, which is also confusing. Still not happy with how this works.
Perhaps a conditional dumps could work somehow (Note boolean flag on first
call to dumps is not safe though).
"""
def __init__(self, handle):
self.handle = handle
def dumps(self):
return "({})".format(self.handle)
def __add__(self, other):
if isinstance(other, tuple):
other = TikzRectCoord(*other)
if isinstance(other, BaseTikzCoord) is False:
raise TypeError("Only can add coordinates with other"
" coordinate types")
return _TikzCalcImplicitCoord(self, "+", other)
def __radd__(self, other):
return self.__add__(other)
def __sub__(self, second, first=None):
"""First - second, optional param for rsubs use"""
if first is None:
first = self
if isinstance(second, tuple):
second = TikzRectCoord(*second)
if isinstance(second, BaseTikzCoord) is False:
raise TypeError("Only can subtract coordinates with other"
" coordinate types")
return _TikzCalcImplicitCoord(first, "-", second)
def __rsub__(self, other):
return self.__sub__(first=other, second=self)
def __mul__(self, other):
if isinstance(other, (float, int, TikzCalcScalar)) is False:
raise TypeError("Coordinates can only be multiplied by scalars")
return _TikzCalcImplicitCoord(other, "*", self)
def __rmul__(self, other):
return self.__mul__(other)
[docs]class TikzCalcCoord(BaseTikzCoord, TikzNode):
r"""Represents the \coordinate syntax for defining a coordinate handle in
TikZ. This itself is a shortcut for a special case of node. Use
get_handle method to retrieve object corresponding to use of the
coordinate handle (as opposed to the initial definition)
"""
packages = [TikzLibrary('calc')]
[docs] def get_handle(self):
"""Retrieves the associated coordinate handle accessor. # noqa: D401
This handle is for the inline re-referencing of the same
coordinate using the label text supplied at definition.
"""
return _TikzCalcCoordHandle(self.handle)
def dumps(self):
"""Return string representation of the node."""
ret_str = []
ret_str.append(Command('coordinate', options=self.options).dumps())
if self.handle is not None:
ret_str.append('({})'.format(self.handle))
if self._node_position is not None:
ret_str.append('at {}'.format(str(self._node_position)))
if self._node_text is not None:
ret_str.append('{{{text}}}'.format(text=self._node_text))
# note text can be empty in / coordinate
return ' '.join(ret_str) + ";" # avoid space on end
def __add__(self, other, error_text="addition"):
raise TypeError("TikzCalcCoord does not support the operation"
" '{}' as it represents the variable "
"definition. \n The handle returned by "
"TikzCalcCoord.get_handle() does support "
"arithmetic operators.".format(error_text))
def __radd__(self, other):
return self.__add__(other)
def __sub__(self, other):
return self.__add__(other, error_text="subtraction")
def __rsub__(self, other):
return self.__sub__(other)
def __mul__(self, other):
return self.__add__(other, error_text="multiplication")
def __rmul__(self, other):
return self.__mul__(other)
[docs]class TikzCalcScalar(LatexObject):
"""Wrapper for multiplication scalar in calc expressions e.g.
($ 4*(3,2.2) $)
Written explicitly as a separate class to enable dumps support.
Simpler than trying to deal with casting floats and strings
without having other string parsing cause issues.
"""
def __init__(self, value):
"""
Args
----
value: float or int
The scalar operator to be applied to the successor coordinate.
"""
self._value = value
def dumps(self):
"""Represent the Scalar as a string in LaTeX syntax valid for a calc
calculation.
"""
return str(round(self._value, 2))
class _TikzCalcImplicitCoord(BaseTikzCoord):
r"""Class representing an implicit coordinate that would be defined in
TikZ using \coordinate. Supports addition/ subtraction of coordinates as
can be done in the TikZ calc library.
Should never be directly instantiated by user.
"""
_legal_operators = ['-', '+']
def __init__(self, *args):
"""
Args
----
args: BaseTikzCoord or str
A list of coordinate elements
"""
self._last_item_type = None
self._arg_list = []
# parse list and verify legality
self._parse_arg_list(args)
def _parse_next_item(self, item):
# assume first item is a point
if self._last_item_type is None:
if self._add_scalar(item):
return
self._add_point_wrapper(
item, error_to_raise=TypeError(
'First element of operator list must '
'be a or coordinate or scalar, got{}'.format(type(item))))
elif self._last_item_type == 'point':
if item == "*":
self._arg_list.append(item)
self._last_item_type = "point,multiplication"
return
try:
self._add_operator(item)
except (TypeError, ValueError):
raise ValueError("Only a valid operator can follow a point")
elif 'multiplication' in self._last_item_type:
if self._last_item_type.startswith('point'):
if self._add_scalar(item):
return
raise ValueError(
'Point multiplication must be followed by a '
'scalar to be legal')
else: # starts with scalar
self._add_point_wrapper(
item, ValueError("Scalar multiplication must be followed "
"by a point to be legal.")
)
elif self._last_item_type == 'operator':
self._add_point_wrapper(
item, error_to_raise=ValueError(
'only a point descriptor can come after an operator'))
elif self._last_item_type == 'scalar':
if item == "*":
self._arg_list.append(item)
self._last_item_type = "scalar,multiplication"
return
else:
raise ValueError("Multiplication symbol * must follow scalar"
" in calc syntax.")
def _add_scalar(self, item) -> bool:
"""Attempt to process item as a scalar, returns result as boolean"""
if isinstance(item, (float, int)):
self._last_item_type = "scalar"
self._arg_list.append(TikzCalcScalar(item))
return True
elif isinstance(item, TikzCalcScalar):
self._last_item_type = "scalar"
self._arg_list.append(item)
return True
return False
def _parse_arg_list(self, args):
for item in args:
# relatively easy error to make so ensure error is descriptive
if isinstance(item, TikzCalcCoord):
raise TypeError(
"TikzCalcCoord is invalid in an arithmetic "
"operation as it represents coordinate definition. "
"Instead, "
"TikzCalcCoord.get_handle() should be used.")
# if we have nested, we expand to have single instance
if isinstance(item, _TikzCalcImplicitCoord):
for i in item._arg_list:
self._parse_next_item(i)
continue
self._parse_next_item(item)
def _add_operator(self, operator):
if isinstance(operator, str):
if operator not in self._legal_operators:
raise ValueError('Illegal user operator type: "{}"'
.format(operator))
else:
raise TypeError('Only string type operators are allowed')
self._arg_list.append(operator)
self._last_item_type = 'operator'
def _add_point_wrapper(self, point, error_to_raise: Exception) -> bool:
try:
self._add_point(point)
return True
except (TypeError, ValueError):
# not a point, do something
raise error_to_raise
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 = _TikzCalcCoordHandle(point.handle)
else:
raise TypeError('Only str, tuple and Tikz Positional '
'classes are allowed,'
' got: {}'.format(type(point)))
# add, finally
self._arg_list.append(_item)
self._last_item_type = 'point'
def __add__(self, other):
if isinstance(other, _TikzCalcImplicitCoord):
args = self._arg_list.copy()
args.append("+")
args.extend(other._arg_list)
return _TikzCalcImplicitCoord(*args)
elif isinstance(other, BaseTikzCoord):
args = self._arg_list.copy()
args.extend(['+', other])
return _TikzCalcImplicitCoord(*args)
raise TypeError("Addition/ Subtraction unsupported for types"
" {} and {}".format(type(self), type(other)))
def __sub__(self, other):
if isinstance(other, _TikzCalcImplicitCoord):
args = self._arg_list.copy()
args.extend(self.negate_signs(other._arg_list))
return _TikzCalcImplicitCoord(*args)
elif isinstance(other, BaseTikzCoord):
args = self._arg_list.copy()
args.extend(["-", other])
return _TikzCalcImplicitCoord(*args)
raise TypeError("Addition/ Subtraction unsupported for types"
" {} and {}".format(type(self), type(other)))
@classmethod
def negate_signs(cls, input_list: list) -> list:
"""Swap + and - (for recursive subtraction)"""
input_list = input_list.copy() # in case input is used
if input_list[0] not in cls._legal_operators:
input_list.insert(0, '+')
out_list = []
for i in input_list:
if isinstance(i, str):
if i == '-':
out_list.append('+')
elif i == '+':
out_list.append('-')
else:
out_list.append(i)
return out_list
def dumps(self):
"""Return representation of the implicit unevaluated coordinates."""
ret_list = []
for item in self._arg_list:
if isinstance(item, str):
ret_list.append(item)
elif isinstance(item, LatexObject):
ret_list.append(item.dumps())
ret_str = ""
for i in ret_list:
# Asterisk in this context is for a calc line,
# which means spaces are invalid, so string them
if i == "*":
ret_str = ret_str[:-1] + str(i)
else:
ret_str += str(i) + " "
return "($ {}$)".format(ret_str)