Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/pypdf/_text_extraction/_layout_mode/_fixed_width_page.py: 14%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

194 statements  

1"""Extract PDF text preserving the layout of the source PDF""" 

2 

3from collections.abc import Iterator 

4from itertools import groupby 

5from math import ceil 

6from pathlib import Path 

7from typing import Any, Literal, Optional, TypedDict 

8 

9from ..._font import Font 

10from ..._utils import logger_warning 

11from .. import LAYOUT_NEW_BT_GROUP_SPACE_WIDTHS 

12from ._text_state_manager import TextStateManager 

13from ._text_state_params import TextStateParams 

14 

15WHITESPACE_LIMIT = 10_000 

16NEWLINE_LIMIT = 1_000 

17 

18 

19class BTGroup(TypedDict): 

20 """ 

21 Dict describing a line of text rendered within a BT/ET operator pair. 

22 If multiple text show operations render text on the same line, the text 

23 will be combined into a single BTGroup dict. 

24 

25 Keys: 

26 tx: x coordinate of first character in BTGroup 

27 ty: y coordinate of first character in BTGroup 

28 font_size: nominal font size 

29 font_height: effective font height 

30 text: rendered text 

31 displaced_tx: x coordinate of last character in BTGroup 

32 flip_sort: -1 if page is upside down, else 1 

33 """ 

34 

35 tx: float 

36 ty: float 

37 font_size: float 

38 font_height: float 

39 text: str 

40 displaced_tx: float 

41 flip_sort: Literal[-1, 1] 

42 

43 

44def resolve_font(fonts: dict[str, Font], name: str) -> Font: 

45 """ 

46 Resolve a Tf font name to a layout mode Font. 

47 

48 A content stream may select a font name that is not declared in the page 

49 resources. Fall back to an uninterpretable font so extraction degrades to 

50 the existing incomplete-output path instead of raising KeyError. 

51 

52 Args: 

53 fonts: font dictionary as returned by PageObject._layout_mode_fonts() 

54 name: font name supplied by a Tf operator 

55 

56 Returns: 

57 Font: the matching font, or an uninterpretable placeholder font. 

58 

59 """ 

60 if name in fonts: 

61 return fonts[name] 

62 logger_warning( 

63 "Font %(name)s is not in the page resources.", 

64 name=name, source=__name__ 

65 ) 

66 return Font("Unknown", encoding={}, interpretable=False) 

67 

68 

69def bt_group(tj_op: TextStateParams, rendered_text: str, displaced_tx: float) -> BTGroup: 

70 """ 

71 BTGroup constructed from a TextStateParams instance, rendered text, and 

72 displaced tx value. 

73 

74 Args: 

75 tj_op (TextStateParams): TextStateParams instance 

76 rendered_text (str): rendered text 

77 displaced_tx (float): x coordinate of last character in BTGroup 

78 

79 """ 

80 return BTGroup( 

81 tx=tj_op.tx, 

82 ty=tj_op.ty, 

83 font_size=tj_op.font_size, 

84 font_height=tj_op.font_height, 

85 text=rendered_text, 

86 displaced_tx=displaced_tx, 

87 flip_sort=-1 if tj_op.flip_vertical else 1, 

88 ) 

89 

90 

91def recurse_to_target_op( 

92 ops: Iterator[tuple[list[Any], bytes]], 

93 text_state_mgr: TextStateManager, 

94 end_target: Literal[b"Q", b"ET"], 

95 fonts: dict[str, Font], 

96 strip_rotated: bool = True, 

97) -> tuple[list[BTGroup], list[TextStateParams]]: 

98 """ 

99 Recurse operators between BT/ET and/or q/Q operators managing the transform 

100 stack and capturing text positioning and rendering data. 

101 

102 Args: 

103 ops: iterator of operators in content stream 

104 text_state_mgr: a TextStateManager instance 

105 end_target: Either b"Q" (ends b"q" op) or b"ET" (ends b"BT" op) 

106 fonts: font dictionary as returned by PageObject._layout_mode_fonts() 

107 

108 Returns: 

109 tuple: list of BTGroup dicts + list of TextStateParams dataclass instances. 

110 

111 """ 

112 # 1 entry per line of text rendered within each BT/ET operation. 

113 bt_groups: list[BTGroup] = [] 

114 

115 # 1 entry per text show operator (Tj/TJ/'/") 

116 tj_ops: list[TextStateParams] = [] 

117 

118 if end_target == b"Q": 

119 # add new q level. cm's added at this level will be popped at next b'Q' 

120 text_state_mgr.add_q() 

121 

122 for operands, op in ops: 

123 # The loop is broken by the end target, or exits normally when there are no more ops. 

124 if op == end_target: 

125 if op == b"Q": 

126 text_state_mgr.remove_q() 

127 if op == b"ET": 

128 if not tj_ops: 

129 return bt_groups, tj_ops 

130 _text = "" 

131 bt_idx = 0 # idx of first tj in this bt group 

132 last_displaced_tx = tj_ops[bt_idx].displaced_tx 

133 last_ty = tj_ops[bt_idx].ty 

134 for _idx, _tj in enumerate( 

135 tj_ops 

136 ): # ... build text from new Tj operators 

137 if strip_rotated and _tj.rotated: 

138 continue 

139 if not _tj.font.interpretable: # generates warning 

140 continue 

141 # if the y position of the text is greater than the font height, assume 

142 # the text is on a new line and start a new group 

143 if abs(_tj.ty - last_ty) > _tj.font_height: 

144 if _text.strip(): 

145 bt_groups.append( 

146 bt_group(tj_ops[bt_idx], _text, last_displaced_tx) 

147 ) 

148 bt_idx = _idx 

149 _text = "" 

150 

151 # if the x position of the text is less than the last x position by 

152 # more than 5 spaces widths, assume the text order should be flipped 

153 # and start a new group 

154 if ( 

155 last_displaced_tx - _tj.tx 

156 > _tj.space_tx * LAYOUT_NEW_BT_GROUP_SPACE_WIDTHS 

157 ): 

158 if _text.strip(): 

159 bt_groups.append( 

160 bt_group(tj_ops[bt_idx], _text, last_displaced_tx) 

161 ) 

162 bt_idx = _idx 

163 last_displaced_tx = _tj.displaced_tx 

164 _text = "" 

165 

166 # calculate excess x translation based on ending tx of previous Tj. 

167 # multiply by bool (_idx != bt_idx) to ensure spaces aren't double 

168 # applied to the first tj of a BTGroup in fixed_width_page(). 

169 excess_tx = round(_tj.tx - last_displaced_tx, 3) * (_idx != bt_idx) 

170 # space_tx could be 0 if either Tz or font_size was 0 for this _tj. 

171 spaces = int(excess_tx // _tj.space_tx) if _tj.space_tx else 0 

172 if spaces > WHITESPACE_LIMIT: 

173 logger_warning( 

174 "Limiting excessive whitespace from %(actual)d to %(limit)d characters.", 

175 actual=spaces, limit=WHITESPACE_LIMIT, source=__name__ 

176 ) 

177 spaces = WHITESPACE_LIMIT 

178 new_text = f'{" " * spaces}{_tj.text}' 

179 

180 last_ty = _tj.ty 

181 _text = f"{_text}{new_text}" 

182 last_displaced_tx = _tj.displaced_tx 

183 if _text: 

184 bt_groups.append(bt_group(tj_ops[bt_idx], _text, last_displaced_tx)) 

185 text_state_mgr.reset_tm() 

186 break 

187 if op == b"q": 

188 bts, tjs = recurse_to_target_op( 

189 ops, text_state_mgr, b"Q", fonts, strip_rotated 

190 ) 

191 bt_groups.extend(bts) 

192 tj_ops.extend(tjs) 

193 elif op == b"cm": 

194 text_state_mgr.add_cm(*operands) 

195 elif op == b"BT": 

196 bts, tjs = recurse_to_target_op( 

197 ops, text_state_mgr, b"ET", fonts, strip_rotated 

198 ) 

199 bt_groups.extend(bts) 

200 tj_ops.extend(tjs) 

201 elif op == b"Tj": 

202 tj_ops.append(text_state_mgr.text_state_params(operands[0])) 

203 elif op == b"TJ": 

204 _tj = text_state_mgr.text_state_params() 

205 for tj_op in operands[0]: 

206 if isinstance(tj_op, bytes): 

207 _tj = text_state_mgr.text_state_params(tj_op) 

208 tj_ops.append(_tj) 

209 else: 

210 text_state_mgr.add_trm(_tj.displacement_matrix(td_offset=tj_op)) 

211 elif op == b"'": 

212 text_state_mgr.reset_trm() 

213 text_state_mgr.add_tm([0, -text_state_mgr.TL]) 

214 tj_ops.append(text_state_mgr.text_state_params(operands[0])) 

215 elif op == b'"': 

216 text_state_mgr.reset_trm() 

217 text_state_mgr.set_state_param(b"Tw", operands[0]) 

218 text_state_mgr.set_state_param(b"Tc", operands[1]) 

219 text_state_mgr.add_tm([0, -text_state_mgr.TL]) 

220 tj_ops.append(text_state_mgr.text_state_params(operands[2])) 

221 elif op in (b"Td", b"Tm", b"TD", b"T*"): 

222 text_state_mgr.reset_trm() 

223 if op == b"Tm": 

224 text_state_mgr.reset_tm() 

225 elif op == b"TD": 

226 text_state_mgr.set_state_param(b"TL", -operands[1]) 

227 elif op == b"T*": 

228 operands = [0, -text_state_mgr.TL] 

229 text_state_mgr.add_tm(operands) 

230 elif op == b"Tf": 

231 text_state_mgr.set_font(resolve_font(fonts, operands[0]), operands[1]) 

232 else: # handle Tc, Tw, Tz, TL, and Ts operators 

233 text_state_mgr.set_state_param(op, operands) 

234 else: 

235 logger_warning( 

236 "Unbalanced target operations, expected %(end_target)r.", 

237 source=__name__, 

238 end_target=end_target, 

239 ) 

240 return bt_groups, tj_ops 

241 

242 

243def y_coordinate_groups( 

244 bt_groups: list[BTGroup], debug_path: Optional[Path] = None 

245) -> dict[int, list[BTGroup]]: 

246 """ 

247 Group text operations by rendered y coordinate, i.e. the line number. 

248 

249 Args: 

250 bt_groups: list of dicts as returned by text_show_operations() 

251 debug_path (Path, optional): Path to a directory for saving debug output. 

252 

253 Returns: 

254 Dict[int, List[BTGroup]]: dict of lists of text rendered by each BT operator 

255 keyed by y coordinate 

256 

257 """ 

258 ty_groups = { 

259 ty: sorted(grp, key=lambda x: x["tx"]) 

260 for ty, grp in groupby( 

261 bt_groups, key=lambda bt_grp: int(bt_grp["ty"] * bt_grp["flip_sort"]) 

262 ) 

263 } 

264 # combine groups whose y coordinates differ by less than the effective font height 

265 # (accounts for mixed fonts and other minor oddities) 

266 last_ty = next(iter(ty_groups)) 

267 last_txs = {int(_t["tx"]) for _t in ty_groups[last_ty] if _t["text"].strip()} 

268 for ty in list(ty_groups)[1:]: 

269 fsz = min(ty_groups[_y][0]["font_height"] for _y in (ty, last_ty)) 

270 txs = {int(_t["tx"]) for _t in ty_groups[ty] if _t["text"].strip()} 

271 # prevent merge if both groups are rendering in the same x position. 

272 no_text_overlap = not (txs & last_txs) 

273 offset_less_than_font_height = abs(ty - last_ty) < fsz 

274 if no_text_overlap and offset_less_than_font_height: 

275 ty_groups[last_ty] = sorted( 

276 ty_groups.pop(ty) + ty_groups[last_ty], key=lambda x: x["tx"] 

277 ) 

278 last_txs |= txs 

279 else: 

280 last_ty = ty 

281 last_txs = txs 

282 if debug_path: # pragma: no cover 

283 import json # noqa: PLC0415 

284 

285 debug_path.joinpath("bt_groups.json").write_text( 

286 json.dumps(ty_groups, indent=2, default=str), "utf-8" 

287 ) 

288 return ty_groups 

289 

290 

291def text_show_operations( 

292 ops: Iterator[tuple[list[Any], bytes]], 

293 fonts: dict[str, Font], 

294 strip_rotated: bool = True, 

295 debug_path: Optional[Path] = None, 

296) -> list[BTGroup]: 

297 """ 

298 Extract text from BT/ET operator pairs. 

299 

300 Args: 

301 ops (Iterator[Tuple[List, bytes]]): iterator of operators in content stream 

302 fonts (Dict[str, Font]): font dictionary 

303 strip_rotated: Removes text if rotated w.r.t. to the page. Defaults to True. 

304 debug_path (Path, optional): Path to a directory for saving debug output. 

305 

306 Returns: 

307 List[BTGroup]: list of dicts of text rendered by each BT operator 

308 

309 """ 

310 state_mgr = TextStateManager() # transformation stack manager 

311 bt_groups: list[BTGroup] = [] # BT operator dict 

312 tj_ops: list[TextStateParams] = [] # Tj/TJ operator data 

313 for operands, op in ops: 

314 if op in (b"BT", b"q"): 

315 bts, tjs = recurse_to_target_op( 

316 ops, state_mgr, b"ET" if op == b"BT" else b"Q", fonts, strip_rotated 

317 ) 

318 bt_groups.extend(bts) 

319 tj_ops.extend(tjs) 

320 elif op == b"Tf": 

321 state_mgr.set_font(resolve_font(fonts, operands[0]), operands[1]) 

322 else: # set Tc, Tw, Tz, TL, and Ts if required. ignores all other ops 

323 state_mgr.set_state_param(op, operands) 

324 

325 if any(tj.rotated for tj in tj_ops): 

326 if strip_rotated: 

327 logger_warning( 

328 "Rotated text discovered. Output will be incomplete.", source=__name__ 

329 ) 

330 else: 

331 logger_warning( 

332 "Rotated text discovered. Layout will be degraded.", source=__name__ 

333 ) 

334 if not all(tj.font.interpretable for tj in tj_ops): 

335 logger_warning( 

336 "PDF contains an uninterpretable font. Output will be incomplete.", source=__name__ 

337 ) 

338 

339 # left align the data, i.e. decrement all tx values by min(tx) 

340 min_x = min((x["tx"] for x in bt_groups), default=0.0) 

341 bt_groups = [ 

342 dict(ogrp, tx=ogrp["tx"] - min_x, displaced_tx=ogrp["displaced_tx"] - min_x) # type: ignore[misc] 

343 for ogrp in sorted( 

344 bt_groups, key=lambda x: (x["ty"] * x["flip_sort"], -x["tx"]), reverse=True 

345 ) 

346 ] 

347 

348 if debug_path: # pragma: no cover 

349 import json # noqa: PLC0415 

350 

351 debug_path.joinpath("bts.json").write_text( 

352 json.dumps(bt_groups, indent=2, default=str), "utf-8" 

353 ) 

354 debug_path.joinpath("tjs.json").write_text( 

355 json.dumps( 

356 tj_ops, indent=2, default=lambda x: getattr(x, "to_dict", str)(x) 

357 ), 

358 "utf-8", 

359 ) 

360 return bt_groups 

361 

362 

363def fixed_char_width(bt_groups: list[BTGroup], scale_weight: float = 1.25) -> float: 

364 """ 

365 Calculate average character width weighted by the length of the rendered 

366 text in each sample for conversion to fixed-width layout. 

367 

368 Args: 

369 bt_groups (List[BTGroup]): List of dicts of text rendered by each 

370 BT operator 

371 

372 Returns: 

373 float: fixed character width 

374 

375 """ 

376 char_widths = [] 

377 for _bt in bt_groups: 

378 _len = len(_bt["text"]) * scale_weight 

379 char_widths.append(((_bt["displaced_tx"] - _bt["tx"]) / _len, _len)) 

380 return sum(_w * _l for _w, _l in char_widths) / sum(_l for _, _l in char_widths) 

381 

382 

383def fixed_width_page( 

384 ty_groups: dict[int, list[BTGroup]], char_width: float, space_vertically: bool, font_height_weight: float 

385) -> str: 

386 """ 

387 Generate page text from text operations grouped by rendered y coordinate. 

388 

389 Args: 

390 ty_groups: dict of text show ops as returned by y_coordinate_groups() 

391 char_width: fixed character width 

392 space_vertically: include blank lines inferred from y distance + font height. 

393 font_height_weight: multiplier for font height when calculating blank lines. 

394 

395 Returns: 

396 str: page text in a fixed width format that closely adheres to the rendered 

397 layout in the source pdf. 

398 

399 """ 

400 lines: list[str] = [] 

401 last_y_coord = 0 

402 table = str.maketrans(dict.fromkeys(range(14, 32), " ")) 

403 for y_coord, line_data in ty_groups.items(): 

404 if space_vertically and lines: 

405 fh = line_data[0]["font_height"] 

406 blank_lines = 0 if fh == 0 else ( 

407 int(abs(y_coord - last_y_coord) / (fh * font_height_weight)) - 1 

408 ) 

409 if blank_lines > NEWLINE_LIMIT: 

410 logger_warning( 

411 "Limiting excessive newlines from %(actual)d to %(limit)d.", 

412 actual=blank_lines, limit=NEWLINE_LIMIT, source=__name__ 

413 ) 

414 blank_lines = NEWLINE_LIMIT 

415 lines.extend([""] * blank_lines) 

416 

417 line_parts = [] # It uses a list to construct the line, avoiding string concatenation. 

418 current_len = 0 # Track the size with int instead of len(str) overhead. 

419 last_disp = 0.0 

420 for bt_op in line_data: 

421 tx = bt_op["tx"] 

422 offset = int(tx // char_width) 

423 needed_spaces = offset - current_len 

424 if needed_spaces > 0 and ceil(last_disp) < int(tx): 

425 if needed_spaces > WHITESPACE_LIMIT: 

426 logger_warning( 

427 "Limiting excessive whitespace from %(actual)d to %(limit)d characters.", 

428 actual=needed_spaces, limit=WHITESPACE_LIMIT, source=__name__ 

429 ) 

430 needed_spaces = WHITESPACE_LIMIT 

431 padding = " " * needed_spaces 

432 line_parts.append(padding) 

433 current_len += needed_spaces 

434 

435 raw_text = bt_op["text"] 

436 text = raw_text.translate(table) 

437 line_parts.append(text) 

438 current_len += len(text) 

439 last_disp = bt_op["displaced_tx"] 

440 

441 full_line = "".join(line_parts).rstrip() 

442 if full_line.strip() or (space_vertically and lines): 

443 lines.append(full_line) 

444 

445 last_y_coord = y_coord 

446 

447 return "\n".join(lines)