Source code for pdfje.style

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__