1"""A dataclass that captures the CTM and Text State for a tj operation"""
2
3import math
4from dataclasses import dataclass, field
5from typing import Any, Union
6
7from ...generic._font import Font
8from .. import mult, orient
9
10
11@dataclass
12class TextStateParams:
13 """
14 Text state parameters and operator values for a single text value in a
15 TJ or Tj PDF operation.
16
17 Attributes:
18 value (bytes | str): the raw text to be rendered.
19 font (Font): font object
20 font_size (int | float): font size
21 Tc (float): character spacing. Defaults to 0.0.
22 Tw (float): word spacing. Defaults to 0.0.
23 Tz (float): horizontal scaling. Defaults to 100.0.
24 TL (float): leading, vertical displacement between text lines. Defaults to 0.0.
25 Ts (float): text rise. Used for super/subscripts. Defaults to 0.0.
26 transform (List[float]): effective transformation matrix.
27 tx (float): x cood of rendered text, i.e. self.transform[4]
28 ty (float): y cood of rendered text. May differ from self.transform[5] per self.Ts.
29 displaced_tx (float): x coord immediately following rendered text
30 space_tx (float): tx for a space character
31 font_height (float): effective font height accounting for CTM
32 flip_vertical (bool): True if y axis has been inverted (i.e. if self.transform[3] < 0.)
33 rotated (bool): True if the text orientation is rotated with respect to the page.
34
35 """
36
37 value: Union[bytes, str]
38 font: Font
39 font_size: Union[int, float]
40 Tc: float = 0.0
41 Tw: float = 0.0
42 Tz: float = 100.0
43 TL: float = 0.0
44 Ts: float = 0.0
45 transform: list[float] = field(
46 default_factory=lambda: [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]
47 )
48 tx: float = field(default=0.0, init=False)
49 ty: float = field(default=0.0, init=False)
50 displaced_tx: float = field(default=0.0, init=False)
51 space_tx: float = field(default=0.0, init=False)
52 font_height: float = field(default=0.0, init=False)
53 flip_vertical: bool = field(default=False, init=False)
54 rotated: bool = field(default=False, init=False)
55 text: str = ""
56 _raw_chars: str = ""
57
58 def __post_init__(self) -> None:
59 decoded_value: str = ""
60 if isinstance(self.value, bytes):
61 if isinstance(self.font.encoding, str):
62 try:
63 # Decode 2-byte UTF-16-BE units
64 self._raw_chars = self.value.decode(self.font.encoding, "surrogatepass")
65 except UnicodeDecodeError:
66 # Fallback for odd byte counts or unmapped 16-bit GIDs/CIDs
67 self._raw_chars = self.value.decode(self.font.encoding, "surrogateescape")
68 else:
69 self._raw_chars = "".join(chr(byte) for byte in self.value)
70 decoded_value = "".join(self.font.encoding.get(x, chr(x)) for x in self.value)
71 self.text = "".join(
72 self.font.character_map.get(x, x) for x in (decoded_value or self._raw_chars)
73 )
74 else:
75 self.text = self.value
76
77 if orient(self.transform) in (90, 270):
78 self.transform = mult(
79 [1.0, -self.transform[1], -self.transform[2], 1.0, 0.0, 0.0],
80 self.transform,
81 )
82 self.rotated = True
83 # self.transform[0] AND self.transform[3] < 0 indicates true rotation.
84 # If only self.transform[3] < 0, the y coords are simply inverted.
85 if orient(self.transform) == 180 and self.transform[0] < -1e-6:
86 self.transform = mult([-1.0, 0.0, 0.0, -1.0, 0.0, 0.0], self.transform)
87 self.rotated = True
88 self.displaced_tx = self.displaced_transform()[4]
89 self.tx = self.transform[4]
90 self.ty = self.render_transform()[5]
91 self.space_tx = round(self.word_tx(self.font.space_char), 3)
92 if self.space_tx < 1e-6:
93 # if the " " char is assigned 0 width (e.g. for fine tuned spacing
94 # with TJ int operators a la crazyones.pdf), calculate space_tx as
95 # a td_offset of -1 * font.space_width where font.space_width is
96 # the space_width calculated in _font.py.
97 self.space_tx = round(self.word_tx("", -self.font.space_width), 3)
98 self.font_height = self.font_size * math.sqrt(
99 self.transform[1] ** 2 + self.transform[3] ** 2
100 )
101 # flip_vertical handles PDFs generated by Microsoft Word's "publish" command.
102 self.flip_vertical = self.transform[3] < -1e-6 # inverts y axis
103
104 def font_size_matrix(self) -> list[float]:
105 """Font size matrix"""
106 return [
107 self.font_size * (self.Tz / 100.0),
108 0.0,
109 0.0,
110 self.font_size,
111 0.0,
112 self.Ts,
113 ]
114
115 def displaced_transform(self) -> list[float]:
116 """Effective transform matrix after text has been rendered."""
117 return mult(self.displacement_matrix(), self.transform)
118
119 def render_transform(self) -> list[float]:
120 """Effective transform matrix accounting for font size, Tz, and Ts."""
121 return mult(self.font_size_matrix(), self.transform)
122
123 def displacement_matrix(
124 self, word: Union[bytes, str, None] = None, td_offset: float = 0.0
125 ) -> list[float]:
126 """
127 Text displacement matrix
128
129 Args:
130 word (bytes | str, optional): Defaults to None in which case self.text displacement is
131 returned.
132 td_offset (float, optional): translation applied by TD operator. Defaults to 0.0.
133
134 """
135 word = word if word is not None else self.value
136 return [1.0, 0.0, 0.0, 1.0, self.word_tx(word, td_offset), 0.0]
137
138 def word_tx(self, word: Union[bytes, str], td_offset: float = 0.0) -> float:
139 """Horizontal text displacement for any word according this text state"""
140 width: float = 0.0
141 if isinstance(word, bytes):
142 word = self._raw_chars
143
144 for char in word:
145 if char == self.font.space_char:
146 width += self.font.space_width
147 else:
148 width += self.font.get_text_width(char)
149
150 return (
151 (self.font_size * ((width - td_offset) / 1000.0))
152 + len(word) * self.Tc
153 + word.count(self.font.space_char) * self.Tw
154 ) * (self.Tz / 100.0)
155
156 @staticmethod
157 def to_dict(inst: "TextStateParams") -> dict[str, Any]:
158 """Dataclass to dict for json.dumps serialization"""
159 return {k: getattr(inst, k) for k in inst.__dataclass_fields__ if k != "font"}