Source code for pdfje.common

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: ...