1# Copyright (c) 2006, Mathieu Fenniak
2# Copyright (c) 2007, Ashish Kulkarni <kulkarni.ashish@gmail.com>
3#
4# All rights reserved.
5#
6# Redistribution and use in source and binary forms, with or without
7# modification, are permitted provided that the following conditions are
8# met:
9#
10# * Redistributions of source code must retain the above copyright notice,
11# this list of conditions and the following disclaimer.
12# * Redistributions in binary form must reproduce the above copyright notice,
13# this list of conditions and the following disclaimer in the documentation
14# and/or other materials provided with the distribution.
15# * The name of the author may not be used to endorse or promote products
16# derived from this software without specific prior written permission.
17#
18# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
19# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
20# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
21# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
22# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
23# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
24# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
25# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
26# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
27# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
28# POSSIBILITY OF SUCH DAMAGE.
29
30from typing import Any, Callable, Optional, Union
31
32from .._codecs import encoding_dict_from_named_encoding
33from ..generic import DictionaryObject, TextStringObject
34from ..generic._font import Font, FontDescriptor
35from . import OrientationNotFoundError, crlf_space_check, get_display_str, get_text_operands, mult
36
37
38def _get_number_operand(operands: list[Any], index: int, default: float) -> float:
39 """Return the operand at index as a float, or default if it is missing or not a number."""
40 if index < len(operands) and isinstance(operands[index], (int, float)):
41 return float(operands[index])
42 return default
43
44
45class TextExtraction:
46 """
47 A class to handle PDF text extraction operations.
48
49 This class encapsulates all the state and operations needed for extracting
50 text from PDF content streams, replacing the nested functions and nonlocal
51 variables in the original implementation.
52 """
53
54 def __init__(self) -> None:
55 # Text extraction state variables
56 self.cm_matrix: list[float] = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]
57 self.tm_matrix: list[float] = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]
58 self.cm_stack: list[
59 tuple[
60 list[float],
61 Optional[DictionaryObject],
62 Font,
63 float,
64 float,
65 float,
66 float,
67 ]
68 ] = []
69
70 # Store the last modified matrices; can be an intermediate position
71 self.cm_prev: list[float] = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]
72 self.tm_prev: list[float] = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]
73
74 # Store the position at the beginning of building the text
75 self.memo_cm: list[float] = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]
76 self.memo_tm: list[float] = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]
77
78 self.char_scale = 1.0
79 self.space_scale = 1.0
80 self._space_width: float = 500.0 # will be set correctly at first Tf
81 self._actual_str_size: dict[str, float] = {
82 "str_widths": 0.0,
83 "str_height": 0.0,
84 } # will be set to string length calculation result
85 self.TL = 0.0
86 self.font_size = 12.0 # init just in case of
87 # (axis index, min, max) of baselines kept on the current extracted line.
88 self._line_span: Optional[tuple[int, float, float]] = None
89
90 # Text extraction variables
91 self.text: str = ""
92 self.output: str = ""
93 self.rtl_dir: bool = False # right-to-left
94 self.font_resource: Optional[DictionaryObject] = None
95 self.font = Font(
96 name = "NotInitialized",
97 sub_type="Unknown",
98 encoding=encoding_dict_from_named_encoding("cp1252"), # WinAnsiEncoding
99 font_descriptor=FontDescriptor(),
100 )
101 self.orientations: tuple[int, ...] = (0, 90, 180, 270)
102 self.visitor_text: Optional[Callable[[Any, Any, Any, Any, Any], None]] = None
103 self.font_resources: dict[str, DictionaryObject] = {}
104 self.fonts: dict[str, Font] = {}
105
106 self.operation_handlers = {
107 b"BT": self._handle_bt,
108 b"ET": self._handle_et,
109 b"q": self._handle_save_graphics_state,
110 b"Q": self._handle_restore_graphics_state,
111 b"cm": self._handle_cm,
112 b"Tz": self._handle_tz,
113 b"Tw": self._handle_tw,
114 b"TL": self._handle_tl,
115 b"Tf": self._handle_tf,
116 b"Td": self._handle_td,
117 b"Tm": self._handle_tm,
118 b"T*": self._handle_t_star,
119 b"Tj": self._handle_tj_operation,
120 }
121
122 def initialize_extraction(
123 self,
124 orientations: tuple[int, ...] = (0, 90, 180, 270),
125 visitor_text: Optional[Callable[[Any, Any, Any, Any, Any], None]] = None,
126 font_resources: Optional[dict[str, DictionaryObject]] = None,
127 fonts: Optional[dict[str, Font]] = None
128 ) -> None:
129 """Initialize the extractor with extraction parameters."""
130 self.orientations = orientations
131 self.visitor_text = visitor_text
132 self.font_resources = font_resources or {}
133 self.fonts = fonts or {}
134
135 # Reset state
136 self.text = ""
137 self.output = ""
138 self.rtl_dir = False
139 self._line_span = None
140
141 def compute_str_widths(self, str_widths: float) -> float:
142 return str_widths / 1000
143
144 def process_operation(self, operator: bytes, operands: list[Any]) -> None:
145 if operator in self.operation_handlers:
146 handler = self.operation_handlers[operator]
147 str_widths = handler(operands)
148
149 # Post-process operations that affect text positioning
150 if operator in {b"Td", b"Tm", b"T*", b"Tj"}:
151 self._post_process_text_operation(str_widths or 0.0)
152
153 def _post_process_text_operation(self, str_widths: float) -> None:
154 """Handle common post-processing for text positioning operations."""
155 text_was_empty = self.text == ""
156 try:
157 self.text, self.output, self.cm_prev, self.tm_prev, self._line_span = crlf_space_check(
158 self.text,
159 (self.cm_prev, self.tm_prev),
160 (self.cm_matrix, self.tm_matrix),
161 (self.memo_cm, self.memo_tm),
162 self.font_resource,
163 self.orientations,
164 self.output,
165 self.font_size,
166 self.visitor_text,
167 str_widths,
168 self.compute_str_widths(self.font_size * self._space_width),
169 self._actual_str_size["str_height"],
170 self._line_span,
171 )
172 if text_was_empty or self.text == "":
173 self.memo_cm = self.cm_matrix.copy()
174 self.memo_tm = self.tm_matrix.copy()
175 except OrientationNotFoundError:
176 pass
177
178 def _handle_tj(
179 self,
180 text: str,
181 operands: list[Union[str, TextStringObject]],
182 cm_matrix: list[float],
183 tm_matrix: list[float],
184 font_resource: Optional[DictionaryObject],
185 font: Font,
186 orientations: tuple[int, ...],
187 font_size: float,
188 rtl_dir: bool,
189 visitor_text: Optional[Callable[[Any, Any, Any, Any, Any], None]],
190 actual_str_size: dict[str, float],
191 ) -> tuple[str, bool, dict[str, float]]:
192 text_operands, is_str_operands, font_widths = get_text_operands(
193 operands, cm_matrix, tm_matrix, font, orientations
194 )
195 if is_str_operands:
196 text += text_operands
197 else:
198 text, rtl_dir, completed = get_display_str(
199 text,
200 cm_matrix,
201 tm_matrix, # text matrix
202 font_resource,
203 font,
204 text_operands,
205 font_size,
206 rtl_dir,
207 visitor_text,
208 )
209 self.output += completed
210 actual_str_size["str_widths"] += font_widths * font_size
211 actual_str_size["str_height"] = font_size
212 return text, rtl_dir, actual_str_size
213
214 def _flush_text(self) -> None:
215 """Flush accumulated text to output and call visitor if present."""
216 self.output += self.text
217 if self.visitor_text is not None:
218 self.visitor_text(self.text, self.memo_cm, self.memo_tm, self.font_resource, self.font_size)
219 self.text = ""
220 self.memo_cm = self.cm_matrix.copy()
221 self.memo_tm = self.tm_matrix.copy()
222
223 # Operation handlers
224
225 def _handle_bt(self, operands: list[Any]) -> None:
226 """Handle BT (Begin Text) operation - Table 5.4 page 405."""
227 self.tm_matrix = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]
228 self._flush_text()
229
230 def _handle_et(self, operands: list[Any]) -> None:
231 """Handle ET (End Text) operation - Table 5.4 page 405."""
232 self._flush_text()
233
234 def _handle_save_graphics_state(self, operands: list[Any]) -> None:
235 """Handle q (Save graphics state) operation - Table 4.7 page 219."""
236 self.cm_stack.append(
237 (
238 self.cm_matrix,
239 self.font_resource,
240 self.font,
241 self.font_size,
242 self.char_scale,
243 self.space_scale,
244 self.TL,
245 )
246 )
247
248 def _handle_restore_graphics_state(self, operands: list[Any]) -> None:
249 """Handle Q (Restore graphics state) operation - Table 4.7 page 219."""
250 try:
251 (
252 self.cm_matrix,
253 self.font_resource,
254 self.font,
255 self.font_size,
256 self.char_scale,
257 self.space_scale,
258 self.TL,
259 ) = self.cm_stack.pop()
260 except Exception:
261 self.cm_matrix = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]
262
263 def _handle_cm(self, operands: list[Any]) -> None:
264 """Handle cm (Modify current matrix) operation - Table 4.7 page 219."""
265 self.output += self.text
266 if self.visitor_text is not None:
267 self.visitor_text(self.text, self.memo_cm, self.memo_tm, self.font_resource, self.font_size)
268 self.text = ""
269 try:
270 self.cm_matrix = mult([float(operand) for operand in operands[:6]], self.cm_matrix)
271 except Exception:
272 self.cm_matrix = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]
273 self.memo_cm = self.cm_matrix.copy()
274 self.memo_tm = self.tm_matrix.copy()
275
276 def _handle_tz(self, operands: list[Any]) -> None:
277 """Handle Tz (Set horizontal text scaling) operation - Table 5.2 page 398."""
278 self.char_scale = _get_number_operand(operands, 0, 100.0) / 100
279
280 def _handle_tw(self, operands: list[Any]) -> None:
281 """Handle Tw (Set word spacing) operation - Table 5.2 page 398."""
282 self.space_scale = 1.0 + _get_number_operand(operands, 0, 0.0)
283
284 def _handle_tl(self, operands: list[Any]) -> None:
285 """Handle TL (Set Text Leading) operation - Table 5.2 page 398."""
286 # The leading is measured in unscaled text space units (PDF 32000-1, 9.3.5)
287 # and T* applies the text matrix to it, so it must not be scaled here.
288 self.TL = _get_number_operand(operands, 0, 0.0)
289
290 def _handle_tf(self, operands: list[Any]) -> None:
291 """Handle Tf (Set font size) operation - Table 5.2 page 398."""
292 if self.text != "":
293 self.output += self.text # .translate(cmap)
294 if self.visitor_text is not None:
295 self.visitor_text(self.text, self.memo_cm, self.memo_tm, self.font_resource, self.font_size)
296 self.text = ""
297 self.memo_cm = self.cm_matrix.copy()
298 self.memo_tm = self.tm_matrix.copy()
299 try:
300 self.font_resource = self.font_resources[operands[0]]
301 self.font = self.fonts[operands[0]]
302 except (KeyError, IndexError): # font not found / operand missing
303 self.font_resource = None
304 font_descriptor = FontDescriptor()
305 self.font = Font(
306 "Unknown",
307 space_width=250,
308 encoding=dict.fromkeys(range(256), "�"),
309 font_descriptor=font_descriptor,
310 character_map={},
311 )
312
313 self._space_width = self.font.space_width / 2 # Actually the width of _half_ a space...
314 try:
315 self.font_size = float(operands[1])
316 except Exception:
317 pass # keep previous size
318
319 def _handle_td(self, operands: list[Any]) -> float:
320 """Handle Td (Move text position) operation - Table 5.5 page 406."""
321 # A special case is a translating only tm:
322 # tm = [1, 0, 0, 1, e, f]
323 # i.e. tm[4] += tx, tm[5] += ty.
324 tx = _get_number_operand(operands, 0, 0.0)
325 ty = _get_number_operand(operands, 1, 0.0)
326 self.tm_matrix[4] += tx * self.tm_matrix[0] + ty * self.tm_matrix[2]
327 self.tm_matrix[5] += tx * self.tm_matrix[1] + ty * self.tm_matrix[3]
328 str_widths = self.compute_str_widths(self._actual_str_size["str_widths"])
329 self._actual_str_size["str_widths"] = 0.0
330 return str_widths
331
332 def _handle_tm(self, operands: list[Any]) -> float:
333 """Handle Tm (Set text matrix) operation - Table 5.5 page 406."""
334 try:
335 tm_matrix = [float(operand) for operand in operands[:6]]
336 except (TypeError, ValueError):
337 tm_matrix = []
338 # Fall back to the identity matrix when Tm carries the wrong number of
339 # operands, mirroring _handle_cm, so the text matrix stays a six-element
340 # list and later positioning operators do not read past its end.
341 self.tm_matrix = tm_matrix if len(tm_matrix) == 6 else [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]
342 str_widths = self.compute_str_widths(self._actual_str_size["str_widths"])
343 self._actual_str_size["str_widths"] = 0.0
344 return str_widths
345
346 def _handle_t_star(self, operands: list[Any]) -> float:
347 """Handle T* (Move to next line) operation - Table 5.5 page 406."""
348 self.tm_matrix[4] -= self.TL * self.tm_matrix[2]
349 self.tm_matrix[5] -= self.TL * self.tm_matrix[3]
350 str_widths = self.compute_str_widths(self._actual_str_size["str_widths"])
351 self._actual_str_size["str_widths"] = 0.0
352 return str_widths
353
354 def _handle_tj_operation(self, operands: list[Any]) -> float:
355 """Handle Tj (Show text) operation - Table 5.5 page 406."""
356 if not operands or not isinstance(operands[0], (str, bytes)):
357 return 0.0
358 self.text, self.rtl_dir, self._actual_str_size = self._handle_tj(
359 self.text,
360 operands,
361 self.cm_matrix,
362 self.tm_matrix,
363 self.font_resource,
364 self.font,
365 self.orientations,
366 self.font_size,
367 self.rtl_dir,
368 self.visitor_text,
369 self._actual_str_size,
370 )
371 return 0.0 # str_widths will be handled in post-processing