from __future__ import annotations
import enum
from dataclasses import dataclass, fields
from itertools import chain, islice, tee
from operator import itemgetter
from typing import (
TYPE_CHECKING,
Any,
Callable,
Generic,
Iterable,
Iterator,
Mapping,
Protocol,
Sequence,
Tuple,
TypeVar,
Union,
final,
overload,
)
Pt = float # not allowed to be inf or nan (math.isfinite)
Char = str # 1-character string
Pos = int # position within a string (index)
HexColor = str # 6-digit hex color, starting with `#`. e.g. #ff0000
Streamable = Iterable[bytes] # PDF stream content
flatten = chain.from_iterable
first = itemgetter(0)
second = itemgetter(1)
Ordinal = int # a unicode code point
NonEmptySequence = Sequence
Tclass = TypeVar("Tclass", bound=type)
T = TypeVar("T")
U = TypeVar("U")
NonEmptyIterator = Iterator[T]
def prepend(i: T, it: Iterable[T]) -> Iterator[T]:
return chain((i,), it)
# From the itertools recipes
def advance(it: Iterator[object], n: int) -> None:
"Advance the iterator n-steps ahead"
next(islice(it, n, n), None)
# Abstract properties don't mix well with dataclass inheritance at runtime.
# Deleting properties at runtime fixes the issue.
# We still use the type checker to ensure subclasses have the right methods.
def fix_abstract_properties(t: Tclass) -> Tclass:
for n in getattr(t, "__abstractmethods__", ()):
try:
delattr(t, n)
except AttributeError:
pass
return t
@dataclass(frozen=True, repr=False)
class always(Generic[T]):
__slots__ = ("_v",)
_v: T
def __call__(self, *_: Any, **__: Any) -> T:
return self._v
def __repr__(self) -> str:
return f"always({self._v!r})"
def peek(it: Iterator[T]) -> tuple[T, Iterator[T]]:
branch, it = tee(it)
return next(branch), it
setattr_frozen = object.__setattr__
# adapted from github.com/ericvsmith/dataclasses
# under its Apache 2.0 license.
def add_slots(cls: Tclass) -> Tclass: # pragma: no cover
if "__slots__" in cls.__dict__:
raise TypeError(f"{cls.__name__} already specifies __slots__")
cls_dict = dict(cls.__dict__)
field_names = tuple(f.name for f in fields(cls))
cls_dict["__slots__"] = field_names
for field_name in field_names:
# Remove our attributes, if present. They'll still be
# available in _MARKER.
cls_dict.pop(field_name, None)
# Remove __dict__ itself.
cls_dict.pop("__dict__", None)
# And finally create the class.
qualname = getattr(cls, "__qualname__", None)
cls = type(cls)(cls.__name__, cls.__bases__, cls_dict)
if qualname is not None:
cls.__qualname__ = qualname
return cls
[docs]
@final
@add_slots
@dataclass(frozen=True, repr=False)
class XY(Sequence[float]):
"""Represents a point, vector, or size in 2D space, where the first
coordinate is the horizontal component and the second is the vertical one.
This class supports some basic operators:
.. code-block:: python
>>> XY(1, 2) + XY(3, 4)
XY(4, 6)
# two-tuples are also supported
>>> XY(1, 2) - (3, 4)
XY(-2, -2)
>>> XY(1, 2) / 2
XY(0.5, 1.0)
>>> XY(1, 2) * 2
XY(2, 4)
It also implements the :class:`~collections.abc.Sequence` protocol:
.. code-block:: python
>>> xy = XY(1, 2)
>>> xy[0]
1
>>> x, y = xy
>>> list(xy)
[1, 2]
"""
x: float = 0
y: float = 0
def __repr__(self) -> str:
return f"XY({self.x}, {self.y})"
def astuple(self) -> tuple[float, float]:
return (self.x, self.y)
@staticmethod
def parse(v: XY | tuple[float, float], /) -> XY:
return XY(*v) if isinstance(v, tuple) else v
def __iter__(self) -> Iterator[float]:
yield self.x
yield self.y
# We don't support slices -- which is technically a Sequence protocol
# violation. But in practice this is not an issue.
def __getitem__(self, i: int) -> float: # type: ignore[override]
if i == 0:
return self.x
elif i == 1:
return self.y
else:
raise IndexError(i)
def __len__(self) -> int:
return 2
def __truediv__(self, other: float | int) -> XY:
if isinstance(other, (float, int)):
return XY(self.x / other, self.y / other)
else:
return NotImplemented # type: ignore[unreachable]
def __mul__(self, other: float | int) -> XY:
if isinstance(other, (float, int)):
return XY(self.x * other, self.y * other)
else:
return NotImplemented # type: ignore[unreachable]
def __sub__(self, other: XY | tuple[float, float]) -> XY:
if isinstance(other, tuple):
return XY(self.x - other[0], self.y - other[1])
elif isinstance(other, XY):
return XY(self.x - other.x, self.y - other.y)
else:
return NotImplemented # type: ignore[unreachable]
def __add__(self, other: XY | tuple[float, float]) -> XY:
if isinstance(other, tuple):
return XY(self.x + other[0], self.y + other[1])
elif isinstance(other, XY):
return XY(self.x + other.x, self.y + other.y)
else:
return NotImplemented # type: ignore[unreachable]
def add_x(self, x: float) -> XY:
return XY(self.x + x, self.y)
def add_y(self, y: float) -> XY:
return XY(self.x, self.y + y)
[docs]
def flip(self) -> XY:
"Return a new XY with x and y swapped"
return XY(self.y, self.x)
class Align(enum.Enum):
"""Horizontal alignment of text."""
LEFT = 0
CENTER = 1
RIGHT = 2
JUSTIFY = 3
@staticmethod
def parse(align: Align | str) -> Align:
if isinstance(align, str):
return Align[align.upper()]
return align
@add_slots
@dataclass(frozen=True)
class Sides(Sequence[float]):
"""Represents a set of four sides. Used for padding and margins."""
top: Pt = 0
right: Pt = 0
bottom: Pt = 0
left: Pt = 0
def __iter__(self) -> Iterator[Pt]:
yield self.top
yield self.right
yield self.bottom
yield self.left
def astuple(self) -> tuple[Pt, Pt, Pt, Pt]:
return (self.top, self.right, self.bottom, self.left)
# We don't support slices -- which is technically a Sequence protocol
# violation. But in practice this is not an issue.
def __getitem__(self, i: int) -> Pt: # type: ignore[override]
if i == 0:
return self.top
elif i == 1:
return self.right
elif i == 2:
return self.bottom
elif i == 3:
return self.left
else:
raise IndexError(i)
def __len__(self) -> int:
return 4
@staticmethod
def parse(v: SidesLike, /) -> Sides:
if isinstance(v, Sides):
return v
elif isinstance(v, tuple):
if len(v) == 4:
return Sides(*v)
elif len(v) == 3:
return Sides(v[0], v[1], v[2], v[1])
elif len(v) == 2:
return Sides(v[0], v[1], v[0], v[1])
else:
raise TypeError(f"Cannot parse {v} as sides")
elif isinstance(v, (float, int)):
return Sides(v, v, v, v)
else:
raise TypeError(f"Cannot parse {v} as sides")
SidesLike = Union[
Sides, Tuple[Pt, Pt, Pt, Pt], Tuple[Pt, Pt, Pt], Tuple[Pt, Pt], Pt
]
[docs]
@final
@add_slots
@dataclass(frozen=True, repr=False)
class RGB(Sequence[float]):
"""Represents a color in RGB space, with values between 0 and 1.
Common colors are available as constants:
.. code-block:: python
from pdfje import red, lime, blue, black, white, yellow, cyan, magenta
Note
----
In most cases where you would use a color, you can use a string
of the form ``#RRGGBB`` or instead, e.g. ``#fa9225``
"""
red: float = 0
green: float = 0
blue: float = 0
def __post_init__(self) -> None:
assert (
self.red <= 1 and self.green <= 1 and self.blue <= 1
), "RGB values too large. They should be between 0 and 1"
# We don't support slices -- which is technically a Sequence protocol
# violation. But in practice this is not an issue.
def __getitem__(self, i: int) -> float: # type: ignore[override]
if i == 0:
return self.red
elif i == 1:
return self.green
elif i == 2:
return self.blue
else:
raise IndexError(i)
def __len__(self) -> int:
return 3
@staticmethod
def parse(v: RGB | tuple[float, float, float] | str, /) -> RGB:
if isinstance(v, tuple):
assert len(v) == 3, "RGB tuple must have 3 values"
return RGB(*v)
elif isinstance(v, str):
assert v.startswith("#"), "RGB string must start with #"
assert len(v) == 7, "RGB string must have 7 characters"
return RGB(
int(v[1:3], 16) / 255,
int(v[3:5], 16) / 255,
int(v[5:7], 16) / 255,
)
else:
assert isinstance(v, RGB), "invalid RGB value"
return v
def astuple(self) -> tuple[float, float, float]:
return (self.red, self.green, self.blue)
def __repr__(self) -> str:
return f"RGB({self.red:.3f}, {self.green:.3f}, {self.blue:.3f})"
# This method cannot be defined in the class body, as it would cause a
# circular import. The implementation is patched into the class
# in the `style` module.
if TYPE_CHECKING: # pragma: no cover
from .style import Style, StyleLike
def __or__(self, _: StyleLike, /) -> Style: ...
def __ror__(self, _: HexColor, /) -> Style: ...
def __iter__(self) -> Iterator[float]:
yield self.red
yield self.green
yield self.blue
red = RGB(1, 0, 0)
lime = RGB(0, 1, 0)
blue = RGB(0, 0, 1)
black = RGB(0, 0, 0)
white = RGB(1, 1, 1)
yellow = RGB(1, 1, 0)
magenta = RGB(1, 0, 1)
cyan = RGB(0, 1, 1)
@add_slots
@dataclass(frozen=True)
class dictget(Generic[T, U]):
_map: Mapping[T, U]
default: U
def __call__(self, k: T) -> U:
try:
return self._map[k]
except KeyError:
return self.default
# The copious overloads are to enable mypy to
# deduce the proper callable types -- up to a limit.
T1 = TypeVar("T1")
T2 = TypeVar("T2")
T3 = TypeVar("T3")
T4 = TypeVar("T4")
T5 = TypeVar("T5")
T6 = TypeVar("T6")
T7 = TypeVar("T7")
T8 = TypeVar("T8")
T9 = TypeVar("T9")
@overload
def pipe() -> Callable[[T1], T1]: ...
@overload # noqa: F811
def pipe(__f1: Callable[[T1], T2]) -> Callable[[T1], T2]: ...
@overload # noqa: F811
def pipe(
__f1: Callable[[T1], T2], __f2: Callable[[T2], T3]
) -> Callable[[T1], T3]: ...
@overload # noqa: F811
def pipe(
__f1: Callable[[T1], T2],
__f2: Callable[[T2], T3],
__f3: Callable[[T3], T4],
) -> Callable[[T1], T4]: ...
@overload # noqa: F811
def pipe(
__f1: Callable[[T1], T2],
__f2: Callable[[T2], T3],
__f3: Callable[[T3], T4],
__f4: Callable[[T4], T5],
) -> Callable[[T1], T5]: ...
@overload # noqa: F811
def pipe(
__f1: Callable[[T1], T2],
__f2: Callable[[T2], T3],
__f3: Callable[[T3], T4],
__f4: Callable[[T4], T5],
__f5: Callable[[T5], T6],
) -> Callable[[T1], T6]: ...
@overload # noqa: F811
def pipe(
__f1: Callable[[T1], T2],
__f2: Callable[[T2], T3],
__f3: Callable[[T3], T4],
__f4: Callable[[T4], T5],
__f5: Callable[[T5], T6],
__f6: Callable[[T6], T7],
) -> Callable[[T1], T7]: ...
@overload # noqa: F811
def pipe(
__f1: Callable[[T1], T2],
__f2: Callable[[T2], T3],
__f3: Callable[[T3], T4],
__f4: Callable[[T4], T5],
__f5: Callable[[T5], T6],
__f6: Callable[[T6], T7],
__f7: Callable[[T7], T8],
) -> Callable[[T1], T8]: ...
@overload # noqa: F811
def pipe(
__f1: Callable[[Any], Any],
__f2: Callable[[Any], Any],
__f3: Callable[[Any], Any],
__f4: Callable[[Any], Any],
__f5: Callable[[Any], Any],
__f6: Callable[[Any], Any],
__f7: Callable[[Any], Any],
*__fn: Callable[[Any], Any],
) -> Callable[[Any], Any]: ...
def pipe(*__fs: Any) -> Any: # noqa: F811
"""Create a new callable by piping several in succession
Example
-------
>>> fn = pipe(float, lambda x: x / 4, int)
>>> fn('9.3')
2
Note
----
* Type checking is supported up to 7 functions,
due to limitations of the Python type system.
"""
return __pipe(__fs)
@dataclass(frozen=True, repr=False)
class __pipe:
__slots__ = ("_functions",)
_functions: tuple[Callable[[Any], Any], ...]
def __call__(self, value: Any) -> Any:
for f in self._functions:
value = f(value)
return value
T_contra = TypeVar("T_contra", contravariant=True)
T_co = TypeVar("T_co", covariant=True)
# shortcut for Callable[[T_contra], T_co]. Necessary for typing
# dataclass fields, as Callable is interpreted incorrectly.
class Func(Protocol[T_contra, T_co]):
def __call__(self, __value: T_contra) -> T_co: ...