from __future__ import annotations
from dataclasses import dataclass, fields
from itertools import chain
from typing import (
TYPE_CHECKING,
ClassVar,
Generator,
Iterator,
Sequence,
TypeVar,
Union,
final,
)
from .common import RGB, HexColor, Pt, add_slots, setattr_frozen
from .fonts.builtins import helvetica
from .fonts.common import BuiltinTypeface, TrueType, Typeface
from .resources import Resources
from .typeset.hyphens import (
Hyphenator,
HyphenatorLike,
default_hyphenator,
parse_hyphenator,
)
from .typeset.state import (
Chain,
Command,
Passage,
SetColor,
SetFont,
SetHyphens,
SetLineSpacing,
State,
)
__all__ = ["Style", "Span", "StyleLike"]
# sentinel value for unset style attributes
class _NOT_SET:
__slots__ = ()
def __repr__(self) -> str:
return "NOT_SET"
_NOTSET = _NOT_SET()
[docs]
@final
@add_slots
@dataclass(frozen=True, init=False)
class Style:
"""Settings for visual style of text. All parameters are optional.
The default style is Helvetica (regular) size 12, with line spacing 1.25
Parameters
----------
font: ~pdfje.fonts.TrueType | ~pdfje.fonts.BuiltinTypeface
Typeface to use.
size: float
Size of the font, in points.
bold: bool
Whether to use bold font.
italic: bool
Whether to use italic font.
color: ~pdfje.RGB | str
Color of the text; can be given as a hex string (e.g. ``#ff0000``)
line_spacing: float
Line spacing, as a multiplier of the font size.
hyphens: ~pyphen.Pyphen | None | ~typing.Callable[[str], ~typing.Iterable[str]]
Hyphenation algorithm to use. Passing an explicit ``None`` disables
hyphenation.
Example
-------
Below we can see how to create a style, and how to combine them.
>>> from pdfje import Style, RGB, times_roman
>>> body = Style(font=times_roman, size=14, italic=True))
>>> heading = body | Style(size=24, bold=True, color=RGB(0.5, 0, 0))
>>> # fonts and colors can be used directly in place of styles
>>> emphasis = times_roman | "#ff0000"
""" # noqa: E501
font: Typeface | None = None
size: Pt | None = None
bold: bool | None = None
italic: bool | None = None
color: RGB | None = None
line_spacing: float | None = None
hyphens: Hyphenator | None = None
def __init__(
self,
font: Typeface | None = None,
size: Pt | None = None,
bold: bool | None = None,
italic: bool | None = None,
color: RGB | tuple[float, float, float] | HexColor | None = None,
line_spacing: float | None = None,
hyphens: HyphenatorLike | _NOT_SET = _NOTSET,
) -> None:
setattr_frozen(self, "font", font)
setattr_frozen(self, "size", size)
setattr_frozen(self, "bold", bold)
setattr_frozen(self, "italic", italic)
setattr_frozen(self, "line_spacing", line_spacing)
setattr_frozen(self, "color", color and RGB.parse(color))
setattr_frozen(
self,
"hyphens",
(
None
if isinstance(hyphens, _NOT_SET)
else parse_hyphenator(hyphens)
),
)
# Use this instead of replace() to avoid triggering __init__.
def _evolve(self, **kwargs: object) -> Style:
attrs = {f.name: getattr(self, f.name) for f in fields(self)}
attrs.update(kwargs)
new = Style.__new__(Style)
for k, v in attrs.items():
setattr_frozen(new, k, v)
return new
def __or__(self, other: StyleLike, /) -> Style:
if isinstance(other, Style):
return self._evolve(
font=other.font or self.font,
size=_fallback(other.size, self.size),
bold=_fallback(other.bold, self.bold),
italic=_fallback(other.italic, self.italic),
color=other.color or self.color,
line_spacing=_fallback(other.line_spacing, self.line_spacing),
hyphens=_fallback(other.hyphens, self.hyphens),
)
elif isinstance(other, (TrueType, BuiltinTypeface)):
return self._evolve(font=other)
elif isinstance(other, str):
return self._evolve(color=RGB.parse(other))
elif isinstance(other, RGB):
return self._evolve(color=other)
else:
return NotImplemented # type: ignore[unreachable]
def __ror__(self, other: HexColor, /) -> Style:
return Style(color=RGB.parse(other)) | self
def __repr__(self) -> str:
field_reprs = [
(f.name, v)
for f in fields(self)
if (v := getattr(self, f.name)) is not None
]
return (
f"Style({', '.join(f'{k}={v!r}' for k, v in field_reprs)})"
if field_reprs
else "Style.EMPTY"
)
@staticmethod
def parse(s: StyleLike) -> Style:
if isinstance(s, Style):
return s
elif isinstance(s, RGB):
return Style(color=s)
elif isinstance(s, (TrueType, BuiltinTypeface)):
return Style(font=s)
elif isinstance(s, str) and s.startswith("#"): # type: ignore
return Style(color=RGB.parse(s))
else:
raise TypeError(f"Cannot parse style from {s!r}")
def diff(self, r: Resources, base: StyleFull) -> Iterator[Command]:
if (
_differs(self.bold, base.bold)
or _differs(self.italic, base.italic)
or _differs(self.font, base.font)
or _differs(self.size, base.size)
):
yield SetFont(
r.font(
self.font or base.font,
_fallback(self.bold, base.bold),
_fallback(self.italic, base.italic),
),
_fallback(self.size, base.size),
)
if _differs(self.color, base.color):
yield SetColor(self.color)
if _differs(self.line_spacing, base.line_spacing):
yield SetLineSpacing(self.line_spacing)
if _differs(self.hyphens, base.hyphens):
yield SetHyphens(self.hyphens)
def setdefault(self) -> StyleFull:
return StyleFull.DEFAULT | self
EMPTY: ClassVar[Style]
StyleLike = Union[Style, RGB, Typeface, HexColor]
Style.EMPTY = Style()
bold = Style(bold=True)
"""Shortcut for bold style."""
italic = Style(italic=True)
"""Shortcut for italic style."""
regular = Style(bold=False, italic=False)
"""Shortcut for regular (non-bold or italic) style."""
@add_slots
@dataclass(frozen=True)
class StyleFull:
font: Typeface
size: Pt
bold: bool
italic: bool
color: RGB
line_spacing: float
hyphens: Hyphenator
def __or__(self, s: Style, /) -> StyleFull:
return StyleFull(
s.font or self.font,
_fallback(s.size, self.size),
_fallback(s.bold, self.bold),
_fallback(s.italic, self.italic),
s.color or self.color,
_fallback(s.line_spacing, self.line_spacing),
s.hyphens or self.hyphens,
)
def as_state(self, res: Resources) -> State:
return State(
res.font(self.font, self.bold, self.italic),
self.size,
self.color,
self.line_spacing,
self.hyphens,
)
def diff(self, res: Resources, base: StyleFull) -> Iterator[Command]:
if not (
self.bold == base.bold
and self.italic == base.italic
and self.font == base.font
and self.size == base.size
):
yield SetFont(
res.font(self.font, self.bold, self.italic),
self.size,
)
if self.color != base.color:
yield SetColor(self.color)
if self.line_spacing != base.line_spacing:
yield SetLineSpacing(self.line_spacing)
if self.hyphens != base.hyphens:
yield SetHyphens(self.hyphens)
DEFAULT: ClassVar[StyleFull]
StyleFull.DEFAULT = StyleFull(
helvetica, 12, False, False, RGB(0, 0, 0), 1.25, default_hyphenator
)
_T = TypeVar("_T")
def _fallback(a: _T | None, b: _T) -> _T:
return b if a is None else a
if TYPE_CHECKING: # pragma: no cover
from typing import TypeGuard
def _differs(a: _T | None, b: _T) -> TypeGuard[_T]:
return a is not None and a != b
else:
def _differs(a: _T | None, b: _T) -> bool:
return a is not None and a != b
class StyledMixin:
"A mixin for shared behavior of styled text classes"
__slots__ = ()
content: Sequence[str | Span]
style: Style
def flatten(
self,
r: Resources,
base: StyleFull,
todo: Iterator[Command] = iter(()),
) -> Generator[Passage, None, Iterator[Command]]:
todo = chain(todo, self.style.diff(r, base))
newbase = base | self.style
for item in self.content:
if isinstance(item, str):
yield Passage(Chain.squash(todo), item)
else:
todo = yield from item.flatten(r, newbase, todo)
return chain(todo, base.diff(r, newbase))
[docs]
@final
@add_slots
@dataclass(frozen=True, init=False)
class Span(StyledMixin):
"""A fragment of text with a style.
Parameters
----------
content: str | Span | ~typing.Sequence[str | Span]
The text to render. Can be a string, or a nested :class:`~pdfje.Span`.
style
The style to render the text with.
See :ref:`tutorial<style>` for more details.
Examples
--------
.. code-block:: python
from pdfje.style import Span, Style, bold
from pdfje.fonts import times_roman
# A simple span
Span("Hello, world!", Style(size=24, color="#ff0000"))
# A nested span
Span([
"Beautiful is ",
Span("better", helvetica | bold),
" than ugly.",
], style=times_roman)
"""
content: Sequence[str | Span]
style: Style
def __init__(
self,
content: str | Span | Sequence[str | Span],
style: StyleLike = Style.EMPTY,
):
if isinstance(content, (str, Span)):
content = [content]
setattr_frozen(self, "content", content)
setattr_frozen(self, "style", Style.parse(style))
# The implementation of these operators are patched onto existing classes here
# to avoid circular imports.
if not TYPE_CHECKING: # pragma: no branch
def _typeface__or__(self, other: StyleLike, /) -> Style:
return Style(font=self) | other
def _typeface__ror__(self, other: HexColor, /) -> Style:
return Style(font=self) | RGB.parse(other)
BuiltinTypeface.__or__ = _typeface__or__
BuiltinTypeface.__ror__ = _typeface__ror__
TrueType.__or__ = _typeface__or__
TrueType.__ror__ = _typeface__ror__
def _rgb__or__(self, other: StyleLike, /) -> Style:
return Style(color=self) | other
def _rgb__ror__(self, other: HexColor, /) -> Style:
return Style(color=self)
RGB.__or__ = _rgb__or__
RGB.__ror__ = _rgb__ror__