from __future__ import annotations
import abc
from dataclasses import dataclass, field
from itertools import chain, count
from pathlib import Path
from typing import TYPE_CHECKING, Iterable, Tuple, Union, final
from .. import atoms
from ..atoms import ASCII
from ..common import (
Char,
Func,
Pos,
Pt,
add_slots,
fix_abstract_properties,
setattr_frozen,
)
from ..compat import pairwise
FontID = bytes # unique, internal identifier assigned to a font within a PDF
GlyphPt = float # length unit in glyph space
TEXTSPACE_TO_GLYPHSPACE = 1000 # See PDF32000-1:2008 (9.7.3)
@fix_abstract_properties
class Font(abc.ABC):
"""A specific font within a typeface"""
__slots__ = ()
@property
@abc.abstractmethod
def id(self) -> FontID: ...
# It's worth caching this value, as it is used often
@property
@abc.abstractmethod
def spacewidth(self) -> GlyphPt: ...
@property
@abc.abstractmethod
def encoding_width(self) -> int:
"""The number of bytes assigned to each character when encoding"""
@abc.abstractmethod
def encode(self, s: str, /) -> bytes: ...
@abc.abstractmethod
def width(self, s: str, /) -> Pt:
"""The total width of the given string (excluding kerning)"""
@staticmethod
@abc.abstractmethod
def charwidth(c: Char, /) -> GlyphPt: ...
@abc.abstractmethod
def kern(self, s: str, /, prev: Char | None) -> Iterable[Kern]: ...
@abc.abstractmethod
def charkern(self, a: Char, b: Char, /) -> GlyphPt: ...
[docs]
@final
@add_slots
@dataclass(frozen=True, init=False)
class TrueType:
"""A TrueType font to be embedded in a PDF
Parameters
----------
regular
The regular (i.e. non-bold, non-italic) .ttf file
bold
The bold .ttf file
italic
The italic .ttf file
bold_italic
The bold italic .ttf file
"""
regular: Path
bold: Path
italic: Path
bold_italic: Path
def __init__(
self,
regular: Path | str,
bold: Path | str,
italic: Path | str,
bold_italic: Path | str,
) -> None:
setattr_frozen(self, "regular", Path(regular))
setattr_frozen(self, "bold", Path(bold))
setattr_frozen(self, "italic", Path(italic))
setattr_frozen(self, "bold_italic", Path(bold_italic))
# 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 ..common import HexColor
from ..style import Style, StyleLike
def __or__(self, _: StyleLike, /) -> Style: ...
def __ror__(self, _: HexColor, /) -> Style: ...
def font(self, bold: bool, italic: bool) -> Path:
if bold:
return self.bold_italic if italic else self.bold
else:
return self.italic if italic else self.regular
[docs]
@final
@add_slots
@dataclass(frozen=True, repr=False)
class BuiltinTypeface:
"""A typeface that is built into the PDF renderer."""
regular: BuiltinFont
bold: BuiltinFont
italic: BuiltinFont
bold_italic: BuiltinFont
# 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 ..common import HexColor
from ..style import Style, StyleLike
def __or__(self, _: StyleLike, /) -> Style: ...
def __ror__(self, _: HexColor, /) -> Style: ...
def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.regular.name.decode()})"
def font(self, bold: bool, italic: bool) -> BuiltinFont:
if bold:
return self.bold_italic if italic else self.bold
else:
return self.italic if italic else self.regular
Typeface = Union[BuiltinTypeface, TrueType]
@final
@add_slots
@dataclass(frozen=True, eq=False)
class BuiltinFont(Font):
name: ASCII
id: FontID
charwidth: Func[Char, GlyphPt] = field(repr=False)
kerning: KerningTable | None = field(repr=False)
spacewidth: Pt = field(init=False, repr=False)
encoding_width = 1
def __post_init__(self) -> None:
setattr_frozen(self, "spacewidth", self.charwidth(" "))
def width(self, s: str) -> Pt:
return sum(map(self.charwidth, s)) / TEXTSPACE_TO_GLYPHSPACE
@staticmethod
def encode(s: str) -> bytes:
# FUTURE: normalize unicode to allow better unicode representation
return s.encode("cp1252", errors="replace")
def kern(self, s: str, /, prev: Char | None) -> Iterable[Kern]:
return kern(self.kerning, s, prev) if self.kerning else ()
def charkern(self, a: Char, b: Char) -> GlyphPt:
return self.kerning((a, b)) if self.kerning else 0
def to_resource(self) -> atoms.Dictionary:
return atoms.Dictionary(
(b"Type", atoms.Name(b"Font")),
(b"Subtype", atoms.Name(b"Type1")),
(b"BaseFont", atoms.Name(self.name)),
(b"Encoding", atoms.Name(b"WinAnsiEncoding")),
)
KerningTable = Func[Tuple[Char, Char], GlyphPt]
Kern = Tuple[Pos, GlyphPt]
def kern(
table: KerningTable,
s: str,
prev: Char | None,
) -> Iterable[Kern]:
for i, pair in zip(
count(not prev),
pairwise(chain(prev, s) if prev else s),
):
if space := table(pair):
yield (i, space)