1"""
2Code related to text extraction.
3
4Some parts are still in _page.py. In doubt, they will stay there.
5"""
6
7import math
8from collections.abc import Mapping
9from typing import Any, Callable, Literal, Optional, Union
10
11from .._utils import is_char_neutral, is_char_rtl
12from ..generic import DictionaryObject, TextStringObject, encode_pdfdocencoding
13from ..generic._font import Font
14
15CUSTOM_RTL_MIN: str = ""
16CUSTOM_RTL_MAX: str = ""
17CUSTOM_RTL_SPECIAL_CHARS: str = ""
18LAYOUT_NEW_BT_GROUP_SPACE_WIDTHS: int = 5
19UNICODE_LOWER_LIMIT = 0
20UNICODE_UPPER_LIMIT = 0x10FFFF
21
22
23class OrientationNotFoundError(Exception):
24 pass
25
26
27def set_custom_rtl(
28 _min: Union[str, int, None] = "",
29 _max: Union[str, int, None] = "",
30 specials: Union[str, list[int], None] = None,
31) -> tuple[str, str, str]:
32 """
33 Change the Right-To-Left and special characters custom parameters.
34
35 Args:
36 _min: The new minimum value for the range of custom characters that
37 will be written right to left.
38 If set to ``None``, the value will not be changed.
39 If set to a valid integer, it will be converted to its corresponding character.
40 The default value is "", which sets no additional range to be converted.
41 _max: The new maximum value for the range of custom characters that will
42 be written right to left.
43 If set to ``None``, the value will not be changed.
44 If set to a valid integer, it will be converted to its corresponding character.
45 The default value is "", which sets no additional range to be converted.
46 specials: The new list of special characters to be inserted in the
47 current insertion order.
48 If set to ``None``, the current value will not be changed.
49 If set to a string, it will be converted to a list of ASCII codes.
50 The default value is an empty list.
51
52 Returns:
53 A tuple containing the new values for ``CUSTOM_RTL_MIN``,
54 ``CUSTOM_RTL_MAX``, and ``CUSTOM_RTL_SPECIAL_CHARS``.
55
56 """
57 global CUSTOM_RTL_MIN, CUSTOM_RTL_MAX, CUSTOM_RTL_SPECIAL_CHARS
58 if isinstance(_min, int):
59 CUSTOM_RTL_MIN = chr(_min) if UNICODE_LOWER_LIMIT <= _min <= UNICODE_UPPER_LIMIT else ""
60 elif isinstance(_min, str):
61 CUSTOM_RTL_MIN = _min
62 if isinstance(_max, int):
63 CUSTOM_RTL_MAX = chr(_max) if UNICODE_LOWER_LIMIT <= _max <= UNICODE_UPPER_LIMIT else ""
64 elif isinstance(_max, str):
65 CUSTOM_RTL_MAX = _max
66 if isinstance(specials, str):
67 CUSTOM_RTL_SPECIAL_CHARS = specials
68 elif isinstance(specials, list):
69 CUSTOM_RTL_SPECIAL_CHARS = "".join(
70 chr(char) for char in specials if UNICODE_LOWER_LIMIT <= char <= UNICODE_UPPER_LIMIT
71 )
72 return CUSTOM_RTL_MIN, CUSTOM_RTL_MAX, CUSTOM_RTL_SPECIAL_CHARS
73
74
75def mult(
76 m: list[float],
77 n: Union[
78 list[float],
79 Mapping[Union[int, Literal["is_text", "is_render"]], Union[float, bool]],
80 ],
81) -> list[float]:
82 return [
83 m[0] * n[0] + m[1] * n[2],
84 m[0] * n[1] + m[1] * n[3],
85 m[2] * n[0] + m[3] * n[2],
86 m[2] * n[1] + m[3] * n[3],
87 m[4] * n[0] + m[5] * n[2] + n[4],
88 m[4] * n[1] + m[5] * n[3] + n[5],
89 ]
90
91
92def orient(m: list[float]) -> int:
93 if m[3] > 1e-6:
94 return 0
95 if m[3] < -1e-6:
96 return 180
97 if m[1] > 0:
98 return 90
99 return 270
100
101
102def crlf_space_check( # noqa: PLR0913, PLR0917
103 text: str,
104 cmtm_prev: tuple[list[float], list[float]],
105 cmtm_matrix: tuple[list[float], list[float]],
106 memo_cmtm: tuple[list[float], list[float]],
107 font_resource: Optional[DictionaryObject],
108 orientations: tuple[int, ...],
109 output: str,
110 font_size: float,
111 visitor_text: Optional[Callable[[Any, Any, Any, Any, Any], None]],
112 str_widths: float,
113 spacewidth: float,
114 str_height: float,
115 line_span: Optional[tuple[int, float, float]],
116) -> tuple[str, str, list[float], list[float], Optional[tuple[int, float, float]]]:
117 cm_prev = cmtm_prev[0]
118 tm_prev = cmtm_prev[1]
119 cm_matrix = cmtm_matrix[0]
120 tm_matrix = cmtm_matrix[1]
121 memo_cm = memo_cmtm[0]
122 memo_tm = memo_cmtm[1]
123
124 m_prev = mult(tm_prev, cm_prev)
125 m = mult(tm_matrix, cm_matrix)
126 orientation = orient(m)
127 delta_x = m[4] - m_prev[4]
128 delta_y = m[5] - m_prev[5]
129 # Table 108 of the 1.7 reference ("Text positioning operators")
130 # delta_x/delta_y are expressed in the coordinate system produced by
131 # text matrix x current transformation matrix, so the scaling factors
132 # they get compared against have to be taken from the same combined
133 # matrices instead of the text matrices alone.
134 scale_prev_x = math.sqrt(m_prev[0]**2 + m_prev[1]**2)
135 scale_prev_y = math.sqrt(m_prev[2]**2 + m_prev[3]**2)
136 scale_y = math.sqrt(m[2]**2 + m[3]**2)
137 cm_prev = m
138
139 if orientation not in orientations:
140 raise OrientationNotFoundError
141 if orientation in (0, 180):
142 moved_height: float = delta_y
143 moved_width: float = delta_x
144 elif orientation in (90, 270):
145 moved_height = delta_x
146 moved_width = delta_y
147 # Keep track of the baseline range of the current extracted line. A centered
148 # row label can fall between the two baselines of a wrapped cell (#4130), so
149 # comparing only with the immediately preceding fragment can incorrectly
150 # insert a line break. Use the established baseline range for that case.
151 axis_index = 5 if orientation in (0, 180) else 4
152 axis = m[axis_index]
153 if line_span is None or line_span[0] != axis_index:
154 distance = abs(moved_height)
155 else:
156 lower, upper = line_span[1], line_span[2]
157 distance = 0.0 if lower <= axis <= upper else min(abs(axis - lower), abs(axis - upper))
158 last = (output + text)[-1:]
159 if distance > 0.8 * min(str_height * scale_prev_y, font_size * scale_y):
160 if last not in ("", "\n"):
161 output += text + "\n"
162 if visitor_text is not None:
163 visitor_text(
164 text + "\n",
165 memo_cm,
166 memo_tm,
167 font_resource,
168 font_size,
169 )
170 text = ""
171 line_span = (axis_index, axis, axis)
172 else:
173 if (
174 (moved_width >= (spacewidth + str_widths) * scale_prev_x)
175 and last not in ("", " ")
176 ):
177 text += " "
178 if last in ("", "\n") or line_span is None or line_span[0] != axis_index:
179 line_span = (axis_index, axis, axis)
180 else:
181 line_span = (axis_index, min(line_span[1], axis), max(line_span[2], axis))
182 tm_prev = tm_matrix.copy()
183 cm_prev = cm_matrix.copy()
184 return text, output, cm_prev, tm_prev, line_span
185
186
187def get_text_operands(
188 operands: list[Union[str, TextStringObject]],
189 cm_matrix: list[float],
190 tm_matrix: list[float],
191 font: Font,
192 orientations: tuple[int, ...]
193) -> tuple[str, bool, float]:
194 text: str = ""
195 is_str_operands = False
196 widths: float = 0.0
197 width_cache: dict[str, float] = {}
198 m = mult(tm_matrix, cm_matrix)
199 orientation = orient(m)
200 raw_characters: str = ""
201 if orientation in orientations and len(operands) > 0:
202 if isinstance(operands[0], str):
203 text = operands[0]
204 is_str_operands = True
205 else:
206 text = ""
207 tt: bytes = (
208 encode_pdfdocencoding(operands[0])
209 if isinstance(operands[0], str)
210 else operands[0]
211 )
212 if isinstance(font.encoding, str): # Apply named encoding
213 try:
214 text = tt.decode(font.encoding, "surrogatepass")
215 except UnicodeDecodeError:
216 # Fallback for odd byte counts or unmapped 16-bit GIDs/CIDs
217 text = tt.decode(font.encoding, "surrogateescape")
218 else: # Apply dict encoding
219 text = "".join(font.encoding.get(x, chr(x)) for x in tt)
220 raw_characters = "".join(chr(byte) for byte in tt)
221 for char in (raw_characters or text):
222 if char == font.space_char:
223 widths += font.space_width
224 else:
225 if char not in width_cache:
226 width_cache[char] = font.get_text_width(char)
227 widths += width_cache[char]
228
229 width_cache.clear()
230 return (text, is_str_operands, widths)
231
232
233def get_display_str(
234 text: str,
235 cm_matrix: list[float],
236 tm_matrix: list[float],
237 font_resource: Optional[DictionaryObject],
238 font: Font,
239 text_operands: str,
240 font_size: float,
241 rtl_dir: bool,
242 visitor_text: Optional[Callable[[Any, Any, Any, Any, Any], None]]
243) -> tuple[str, bool, str]:
244 """
245 Add the characters of ``text_operands`` to ``text`` in display order.
246
247 Returns:
248 A tuple containing the current text run, its direction, and any text runs completed
249 because of a direction change. The completed runs precede the current run in display order.
250
251 """
252 # "\u0590 - \u08FF \uFB50 - \uFDFF"
253 completed_text: list[str] = []
254 neutral_cache: dict[str, bool] = {}
255 rtl_cache: dict[str, bool] = {}
256
257 def clear_character_caches() -> None:
258 neutral_cache.clear()
259 rtl_cache.clear()
260
261 for raw_character in text_operands:
262 x = font.character_map.get(raw_character, raw_character)
263 # Test whether x is a sequence of bytes; ex: habibi.pdf
264 if len(x) == 1:
265 if x not in neutral_cache:
266 neutral_cache[x] = is_char_neutral(x, CUSTOM_RTL_SPECIAL_CHARS)
267 if neutral_cache[x]:
268 # Cases where the current inserting order is kept
269 text = x + text if rtl_dir else text + x
270 else:
271 if x not in rtl_cache:
272 rtl_cache[x] = is_char_rtl(x, CUSTOM_RTL_MIN, CUSTOM_RTL_MAX)
273 if rtl_cache[x]:
274 # Right-to-left characters
275 if not rtl_dir:
276 rtl_dir = True
277 if visitor_text is not None:
278 visitor_text(text, cm_matrix, tm_matrix, font_resource, font_size)
279 clear_character_caches()
280 completed_text.append(text)
281 text = ""
282 text = x + text
283 else:
284 # Left-to-right characters
285 if rtl_dir:
286 rtl_dir = False
287 if visitor_text is not None:
288 visitor_text(text, cm_matrix, tm_matrix, font_resource, font_size)
289 clear_character_caches()
290 completed_text.append(text)
291 text = ""
292 text = text + x
293 else:
294 # Treat a sequence of bytes as a neutral character.
295 text = x + text if rtl_dir else text + x
296 return text, rtl_dir, "".join(completed_text)