1"""manage the PDF transform stack during "layout" mode text extraction"""
2
3from collections import ChainMap, Counter
4from collections import ChainMap as ChainMapType
5from collections import Counter as CounterType
6from collections.abc import MutableMapping
7from typing import Any, Literal, Union
8
9from ..._font import Font
10from ...errors import PdfReadError
11from .. import mult
12from ._text_state_params import TextStateParams
13
14TextStateManagerKeyType = Union[int, Literal["is_text", "is_render"]]
15TextStateManagerChainMapType = ChainMapType[TextStateManagerKeyType, Union[float, bool]]
16TextStateManagerDictType = MutableMapping[TextStateManagerKeyType, Union[float, bool]]
17
18
19class TextStateManager:
20 """
21 Tracks the current text state including cm/tm/trm transformation matrices.
22
23 Attributes:
24 transform_stack (ChainMap): ChainMap of cm/tm transformation matrices
25 q_queue (Counter[int]): Counter of q operators
26 q_depth (List[int]): list of q operator nesting levels
27 Tc (float): character spacing
28 Tw (float): word spacing
29 Tz (int): horizontal scaling
30 TL (float): leading
31 Ts (float): text rise
32 font (Font): font object
33 font_size (int | float): font size
34
35 """
36
37 def __init__(self) -> None:
38 self.transform_stack: TextStateManagerChainMapType = ChainMap(
39 self.new_transform()
40 )
41 self.q_queue: CounterType[int] = Counter()
42 self.q_depth = [0]
43 self.Tc: float = 0.0
44 self.Tw: float = 0.0
45 self.Tz: float = 100.0
46 self.TL: float = 0.0
47 self.Ts: float = 0.0
48 self.font_stack: list[tuple[Union[Font, None], Union[int, float]]] = []
49 self.font: Union[Font, None] = None
50 self.font_size: Union[int, float] = 0
51
52 def set_state_param(self, op: bytes, value: Union[float, list[Any]]) -> None:
53 """
54 Set a text state parameter. Supports Tc, Tz, Tw, TL, and Ts operators.
55
56 Args:
57 op: operator read from PDF stream as bytes. No action is taken
58 for unsupported operators (see supported operators above).
59 value (float | List[Any]): new parameter value. If a list,
60 value[0] is used.
61
62 """
63 if op not in [b"Tc", b"Tz", b"Tw", b"TL", b"Ts"]:
64 return
65 self.__setattr__(op.decode(), value[0] if isinstance(value, list) else value)
66
67 def set_font(self, font: Font, size: float) -> None:
68 """
69 Set the current font and font_size.
70
71 Args:
72 font (Font): a layout mode Font
73 size (float): font size
74
75 """
76 self.font = font
77 self.font_size = size
78
79 def text_state_params(self, value: Union[bytes, str] = "") -> TextStateParams:
80 """
81 Create a TextStateParams instance to display a text string. Type[bytes] values
82 will be decoded implicitly.
83
84 Args:
85 value (str | bytes): text to associate with the captured state.
86
87 Raises:
88 PdfReadError: if font not set (no Tf operator in incoming pdf content stream)
89
90 Returns:
91 TextStateParams: current text state parameters
92
93 """
94 if not isinstance(self.font, Font):
95 raise PdfReadError(
96 "font not set: is PDF missing a Tf operator?"
97 ) # pragma: no cover
98 return TextStateParams(
99 value,
100 self.font,
101 self.font_size,
102 self.Tc,
103 self.Tw,
104 self.Tz,
105 self.TL,
106 self.Ts,
107 self.effective_transform,
108 )
109
110 @staticmethod
111 def raw_transform(
112 _a: float = 1.0,
113 _b: float = 0.0,
114 _c: float = 0.0,
115 _d: float = 1.0,
116 _e: float = 0.0,
117 _f: float = 0.0,
118 ) -> TextStateManagerDictType:
119 """Only a/b/c/d/e/f matrix params"""
120 return dict(zip(range(6), map(float, (_a, _b, _c, _d, _e, _f))))
121
122 @staticmethod
123 def new_transform(
124 _a: float = 1.0,
125 _b: float = 0.0,
126 _c: float = 0.0,
127 _d: float = 1.0,
128 _e: float = 0.0,
129 _f: float = 0.0,
130 is_text: bool = False,
131 is_render: bool = False,
132 ) -> TextStateManagerDictType:
133 """Standard a/b/c/d/e/f matrix params + 'is_text' and 'is_render' keys"""
134 result = TextStateManager.raw_transform(_a, _b, _c, _d, _e, _f)
135 result.update({"is_text": is_text, "is_render": is_render})
136 return result
137
138 def reset_tm(self) -> TextStateManagerChainMapType:
139 """Clear all transforms from chainmap having is_text==True or is_render==True"""
140 while (
141 self.transform_stack.maps[0]["is_text"]
142 or self.transform_stack.maps[0]["is_render"]
143 ):
144 self.transform_stack = self.transform_stack.parents
145 return self.transform_stack
146
147 def reset_trm(self) -> TextStateManagerChainMapType:
148 """Clear all transforms from chainmap having is_render==True"""
149 while self.transform_stack.maps[0]["is_render"]:
150 self.transform_stack = self.transform_stack.parents
151 return self.transform_stack
152
153 def remove_q(self) -> TextStateManagerChainMapType:
154 """Rewind to stack prior state after closing a 'q' with internal 'cm' ops"""
155 self.font, self.font_size = self.font_stack.pop(-1)
156 self.transform_stack = self.reset_tm()
157 self.transform_stack.maps = self.transform_stack.maps[
158 self.q_queue.pop(self.q_depth.pop(), 0) :
159 ]
160 return self.transform_stack
161
162 def add_q(self) -> None:
163 """Add another level to q_queue"""
164 self.font_stack.append((self.font, self.font_size))
165 self.q_depth.append(len(self.q_depth))
166
167 def add_cm(self, *args: Any) -> TextStateManagerChainMapType:
168 """Concatenate an additional transform matrix"""
169 self.transform_stack = self.reset_tm()
170 self.q_queue.update(self.q_depth[-1:])
171 self.transform_stack = self.transform_stack.new_child(self.new_transform(*args))
172 return self.transform_stack
173
174 def _complete_matrix(self, operands: list[float]) -> list[float]:
175 """Adds a, b, c, and d to an "e/f only" operand set (e.g Td)"""
176 if len(operands) == 2: # this is a Td operator or equivalent
177 operands = [1.0, 0.0, 0.0, 1.0, *operands]
178 return operands
179
180 def add_tm(self, operands: list[float]) -> TextStateManagerChainMapType:
181 """Append a text transform matrix"""
182 self.transform_stack = self.transform_stack.new_child(
183 self.new_transform( # type: ignore[misc]
184 *self._complete_matrix(operands), is_text=True # type: ignore[arg-type]
185 )
186 )
187 return self.transform_stack
188
189 def add_trm(self, operands: list[float]) -> TextStateManagerChainMapType:
190 """Append a text rendering transform matrix"""
191 self.transform_stack = self.transform_stack.new_child(
192 self.new_transform( # type: ignore[misc]
193 *self._complete_matrix(operands), is_text=True, is_render=True # type: ignore[arg-type]
194 )
195 )
196 return self.transform_stack
197
198 @property
199 def effective_transform(self) -> list[float]:
200 """Current effective transform accounting for cm, tm, and trm transforms"""
201 eff_transform = [*self.transform_stack.maps[0].values()]
202 for transform in self.transform_stack.maps[1:]:
203 eff_transform = mult(eff_transform, transform)
204 return eff_transform