modm_data.pdf2html.page
1# Copyright 2022, Niklas Hauser 2# SPDX-License-Identifier: MPL-2.0 3 4import logging 5import math 6import statistics 7from typing import Callable 8from functools import cached_property 9from collections import defaultdict 10from .table import Table 11from .figure import Figure 12from .line import CharLine 13from ..utils import Rectangle, Region, list_strip 14from ..pdf import Page as PdfPage, Character 15from anytree import Node 16 17 18_LOGGER = logging.getLogger(__name__) 19 20 21class Page(PdfPage): 22 def __init__(self, document, index: int): 23 super().__init__(document, index) 24 self._template = "default" 25 self.is_relevant: bool = True 26 """Is this page relevant for the conversion?""" 27 28 def _unicode_filter(self, code: int) -> int: 29 return code 30 31 @cached_property 32 def _spacing(self) -> dict[str, float]: 33 content = 0.1 34 return { 35 # Horizontal spacing: left->right 36 "x_em": 0.01 * self.width, 37 "x_left": content * self.width, 38 "x_right": (1 - content) * self.width, 39 "x_content": 0.2 * self.width, 40 # Vertical spacing: bottom->top 41 "y_em": 0.01 * self.height, 42 # Max table line thickness 43 "y_tline": 0.005 * self.height, 44 # Max line height distance to detect paragraphs 45 "lh": 0.9, 46 # Max line height distance to detect super-/subscript 47 "sc": 0.3, 48 # Table header cell bold text threshold 49 "th": 0.3, 50 } 51 52 def _line_size(self, line: CharLine) -> str: 53 rsize = line.height 54 if rsize >= 17.5: 55 return "h1" 56 elif rsize >= 15.5: 57 return "h2" 58 elif rsize >= 13.5: 59 return "h3" 60 elif rsize >= 11.4: 61 return "h4" 62 elif rsize >= 8.5: 63 return "n" 64 else: 65 return "fn" 66 67 def _colors(self, color: int) -> str: 68 if 0xFF <= color <= 0xFF: 69 return "black" 70 if 0xFFFFFFFF <= color <= 0xFFFFFFFF: 71 return "white" 72 return "unknown" 73 74 @cached_property 75 def _areas(self) -> dict[str, list[Rectangle] | Rectangle]: 76 content = Rectangle(0.1, 0.1, 0.9, 0.9) 77 areas = {"content": [content]} 78 scaled_areas = {} 79 80 def _s(r): 81 return Rectangle(r.left * self.width, r.bottom * self.height, r.right * self.width, r.top * self.height) 82 83 for name, area in areas.items(): 84 scaled_areas[name] = [_s(r) for r in area] if isinstance(area, list) else _s(area) 85 return scaled_areas 86 87 def _char_properties(self, line, char): 88 cp = { 89 "superscript": False, 90 "subscript": False, 91 "bold": any(frag in char.font for frag in {"Bold"}), 92 "italic": any(frag in char.font for frag in {"Italic", "Oblique"}), 93 "underline": (char.objlink or char.weblink) is not None, 94 "size": round(line.height), 95 "relsize": self._line_size(line), 96 "char": chr(char.unicode), 97 } 98 # Sub- and superscript use a smaller font, characters of the full size 99 # with a shifted baseline are labels next to multi-line text instead 100 if char.height >= 0.95 * line.height: 101 pass 102 elif line.rotation: 103 if char.origin.x < (line.origin - 0.25 * line.height): 104 cp["superscript"] = True 105 elif char.origin.x > (line.origin + 0.15 * line.height): 106 cp["subscript"] = True 107 elif char.origin.y > (line.origin + 0.25 * line.height): 108 cp["superscript"] = True 109 elif char.origin.y < (line.origin - 0.15 * line.height): 110 cp["subscript"] = True 111 return cp 112 113 def text_in_named_area(self, name: str, check_length: bool = True) -> str | None: 114 """ 115 Find all text in the named area. 116 117 :param name: the name of the area(s) to query. 118 :param check_length: assert that the text has a length. 119 :return: the concatenated text of the named area(s) or `None` if area not found. 120 """ 121 if name not in self._areas: 122 return None 123 text = "" 124 areas = self._areas[name] 125 if not isinstance(areas, list): 126 areas = [areas] 127 for area in areas: 128 text += self.text_in_area(area) 129 if check_length: 130 assert text 131 return text 132 133 def charlines_in_area( 134 self, area: Rectangle, predicate: Callable[[Character], bool] = None, rtol: float = None 135 ) -> list[CharLine]: 136 """ 137 Coalesce the characters in the area and predicate into lines. 138 139 1. Every character in the area is filtered by the `predicate`. 140 2. Character orientation is split into horizontal (left->right) and 141 vertical (bottom->top) character lines sorted by x or y position. 142 Lines containing only whitespace are discarded. 143 3. Overlapping character lines are merged into sub- and superscript 144 using `rtol * max(current_line.height, next_line.height)` as the 145 tolerance for checking if the lines overlap. 146 4. The characters in the merged lines are re-sorted by origin. 147 148 :param area: Area to search for characters. 149 :param predicate: Function to discard characters in the area or include all by default. 150 :param rtol: Relative tolerance to separate lines vertically or use `sc` spacing by default. 151 :return: A list of character lines sorted by x or y position. 152 """ 153 if rtol is None: 154 rtol = self._spacing["sc"] 155 # Split all chars into lines based on rounded origin 156 origin_lines_y = defaultdict(list) 157 origin_lines_x = defaultdict(list) 158 for char in self.chars_in_area(area): 159 # Ignore all characters we don't want 160 if predicate is not None and not predicate(char): 161 continue 162 cunicode = self._unicode_filter(char.unicode) 163 if cunicode is None: 164 continue 165 char.unicode = cunicode 166 if char.unicode < 32 and char.unicode not in {0xA}: 167 continue 168 # Ignore characters without width that are not spaces 169 if not char.width and char.unicode not in {0xA, 0xD, 0x20}: 170 _LOGGER.error(f"Unknown char width for {char}: {char.bbox}") 171 # Split up the chars depending on the orientation 172 if 45 < char.rotation <= 135 or 225 < char.rotation <= 315: 173 origin_lines_x[round(char.origin.x, 1)].append(char) 174 elif char.rotation <= 45 or 135 < char.rotation <= 225 or 315 < char.rotation: 175 origin_lines_y[round(char.origin.y, 1)].append(char) 176 else: 177 _LOGGER.error("Unknown char rotation:", char, char.rotation) 178 179 # Convert characters into lines 180 bbox_lines_y = [] 181 for chars in origin_lines_y.values(): 182 # Remove lines with whitespace only 183 if all(c.unicode in {0xA, 0xD, 0x20} for c in chars): 184 continue 185 origin = statistics.fmean(c.origin.y for c in chars) 186 line = CharLine( 187 self, 188 chars, 189 min(c.bbox.bottom for c in chars), 190 origin, 191 max(c.bbox.top for c in chars), 192 max(c.height for c in chars), 193 sort_origin=self.height - origin, 194 ) 195 bbox_lines_y.append(line) 196 # print(line, line.top, line.origin, line.bottom, line.height) 197 bbox_lines = sorted(bbox_lines_y, key=lambda line: line._sort_origin) 198 199 bbox_lines_x = [] 200 for chars in origin_lines_x.values(): 201 # Remove lines with whitespace only 202 if all(c.unicode in {0xA, 0xD, 0x20} for c in chars): 203 continue 204 line = CharLine( 205 self, 206 chars, 207 min(c.bbox.left for c in chars), 208 statistics.fmean(c.origin.x for c in chars), 209 max(c.bbox.right for c in chars), 210 max(c.width for c in chars), 211 270 if sum(c.rotation for c in chars) <= 135 * len(chars) else 90, 212 ) 213 bbox_lines_x.append(line) 214 bbox_lines += sorted(bbox_lines_x, key=lambda line: line._sort_origin) 215 216 if not bbox_lines: 217 return [] 218 219 def _stacked(line0, line1) -> bool: 220 # Characters of the same font size, that overlap along the line by 221 # more than half their width, are printed on top of each other. 222 horizontal = not line0.rotation 223 224 def _spans(line): 225 chars = (c for c in line.chars if c.unicode not in {0x20, 0xA, 0xD}) 226 chars = (c for c in chars if math.isclose(c.height, line.height, rel_tol=0.01)) 227 return [(c.bbox.left, c.bbox.right) if horizontal else (c.bbox.bottom, c.bbox.top) for c in chars] 228 229 spans1 = _spans(line1) 230 for start0, stop0 in _spans(line0): 231 for start1, stop1 in spans1: 232 overlap = min(stop0, stop1) - max(start0, start1) 233 if overlap > 0.5 * min(stop0 - start0, stop1 - start1): 234 return True 235 return False 236 237 def _label(line0, line1) -> bool: 238 # A label of the same font size left of a multi-line text is 239 # vertically centered on the lines, so its baseline is shifted by 240 # more than a continuation of the line would be. 241 if line0.rotation or line1.rotation: 242 return False 243 offset = abs(line0._sort_origin - line1._sort_origin) 244 return offset > rtol * line0.height and ( 245 line0.bbox.right < line1.bbox.left or line1.bbox.right < line0.bbox.left 246 ) 247 248 # Merge lines that have overlapping bbox_lines 249 merged_lines = [] 250 current_line = bbox_lines[0] 251 for next_line in bbox_lines[1:]: 252 height = max(current_line.height, next_line.height) 253 same_height = math.isclose(current_line.height, next_line.height, rel_tol=0.01) 254 # Calculate overlap via normalize origin (increasing with line index) 255 if ( 256 # Horizontal and vertical lines have different sort origins 257 bool(current_line.rotation) == bool(next_line.rotation) 258 and (current_line._sort_origin + rtol * height) > (next_line._sort_origin - rtol * height) 259 # Lines of the same font size printed on top of each other are 260 # separate lines, like the numerator and denominator of a formula 261 and not (same_height and _stacked(current_line, next_line)) 262 and not (same_height and _label(current_line, next_line)) 263 ): 264 # The next line overlaps this one, we merge the shorter line 265 # (typically super- and subscript) into the taller line. Lines 266 # of the same font size continue each other with a slightly 267 # shifted baseline, so we merge them into the longer line. 268 if same_height: 269 use_current = len(current_line.chars) >= len(next_line.chars) 270 else: 271 use_current = current_line.height > next_line.height 272 line = current_line if use_current else next_line 273 current_line = CharLine( 274 self, 275 current_line.chars + next_line.chars, 276 line.bottom, 277 line.origin, 278 line.top, 279 height, 280 line.rotation, 281 sort_origin=line._sort_origin, 282 ) 283 else: 284 # The next line does not overlap the current line 285 merged_lines.append(current_line) 286 current_line = next_line 287 # append last line 288 merged_lines.append(current_line) 289 290 # Prepend labels to the first line of their multi-line text 291 labeled_lines = [] 292 for line in merged_lines: 293 prev = labeled_lines[-1] if labeled_lines else None 294 if ( 295 prev is not None 296 and not line.rotation 297 and math.isclose(prev.height, line.height, rel_tol=0.01) 298 and 0 < line._sort_origin - prev._sort_origin < line.height 299 and line.bbox.right < prev.bbox.left 300 ): 301 # Keep the spaces between the words of the label, but not around it 302 label = sorted((c for c in line.chars if c.unicode not in {0xA, 0xD}), key=lambda c: c.origin.x) 303 label = list_strip(label, lambda c: c.unicode == 0x20) 304 chars = label + [label[-1].generated_space()] + prev.chars 305 labeled_lines[-1] = CharLine( 306 self, chars, prev.bottom, prev.origin, prev.top, prev.height, sort_origin=prev._sort_origin 307 ) 308 else: 309 labeled_lines.append(line) 310 merged_lines = labeled_lines 311 312 # Sort all lines horizontally based on character origin 313 sorted_lines = [] 314 for line in merged_lines: 315 if line.rotation == 90: 316 317 def sort_key(char): 318 if char.unicode in {0xA, 0xD}: 319 return char.tbbox.midpoint.y - 1e9 320 return char.tbbox.midpoint.y 321 elif line.rotation == 270: 322 323 def sort_key(char): 324 if char.unicode in {0xA, 0xD}: 325 return -char.tbbox.midpoint.y + 1e9 326 return -char.tbbox.midpoint.y 327 else: 328 329 def sort_key(char): 330 if char.unicode in {0xA, 0xD}: 331 return char.origin.x + 1e9 332 return char.origin.x 333 334 chars = sorted(line.chars, key=sort_key) 335 if not line.rotation: 336 chars = self._chars_with_bullet_space(chars) 337 chars = self._chars_with_gap_spaces(chars, line.rotation) 338 sorted_lines.append( 339 CharLine( 340 self, 341 chars, 342 line.bottom, 343 line.origin, 344 line.top, 345 line.height, 346 line.rotation, 347 area.left, 348 sort_origin=line._sort_origin, 349 ) 350 ) 351 352 return sorted_lines 353 354 @staticmethod 355 def _chars_with_gap_spaces(chars: list, rotation: int) -> list: 356 # pdfium generates no spaces at the word gaps of fonts without a space 357 # glyph, so we insert them at gaps wider than the typical gap between 358 # the letters of a run of the same font, which may be letter-spaced. 359 def _span(char): 360 if not rotation: 361 return char.bbox.left, char.bbox.right 362 if rotation == 90: 363 return char.bbox.bottom, char.bbox.top 364 return -char.bbox.top, -char.bbox.bottom 365 366 runs, last, key = [], None, None 367 for index, char in enumerate(chars): 368 if char.unicode in {0x20, 0xA, 0xD}: 369 continue 370 start, stop = _span(char) 371 size = round(char.height, 1) 372 # Lines may interleave the characters of several baselines 373 baseline = round(char.origin.x if rotation else char.origin.y, 1) 374 if key != (char.font, size, baseline) or start - last > 0.5 * size: 375 runs.append((char, [])) 376 else: 377 runs[-1][1].append((start - last, index)) 378 key, last = (char.font, size, baseline), stop 379 380 spaces = [] 381 for first, gaps in runs: 382 if not gaps or first.has_space_glyph: 383 continue 384 size = first.height 385 letter_gap = statistics.median(g for g, _ in gaps) 386 spaces += [i for g, i in gaps if g > letter_gap + 0.15 * size and chars[i - 1].unicode != 0x20] 387 for index in sorted(spaces, reverse=True): 388 chars = chars[:index] + [chars[index].generated_space()] + chars[index:] 389 return chars 390 391 @staticmethod 392 def _chars_with_bullet_space(chars: list) -> list: 393 # Newer pdfium does not generate a space for every visual gap anymore, 394 # but the list detection relies on the one between bullet and text. 395 for ii, (bullet, char) in enumerate(zip(chars, chars[1:]), 1): 396 if bullet.unicode == 0x20: 397 continue 398 if ( 399 bullet.char in {"•", "–"} 400 and char.unicode not in {0x20, 0xA, 0xD} 401 and char.origin.x - bullet.bbox.right >= 0.25 * bullet.height 402 ): 403 return chars[:ii] + [char.generated_space()] + chars[ii:] 404 break 405 return chars 406 407 def graphic_bboxes_in_area( 408 self, area: Rectangle, with_graphics: bool = True 409 ) -> list[tuple[Rectangle, Table | Figure | None]]: 410 """ 411 Coalesce the graphics in the area into full width bounding boxes. 412 413 1. Group vertically overlapping graphics. 414 2. Widen the overlapped graphics bounding boxes to the edges of the area. 415 416 :param area: area to search for content. 417 :param with_graphics: search for graphics in the area. 418 :return: list of tuples (bounding box, graphic objects or `None`). 419 """ 420 if with_graphics: 421 graphics = self.graphics_in_area(area) 422 regions = [] 423 # Check if graphics bounding boxes overlap vertically and group them 424 for graphic in sorted(graphics, key=lambda g: (-g.bbox.top, g.bbox.x)): 425 gbbox = graphic.bbox.joined(graphic.cbbox) if graphic.cbbox else graphic.bbox 426 for reg in regions: 427 if reg.overlaps(gbbox.bottom, gbbox.top): 428 # They overlap, so merge them 429 reg.v0 = min(reg.v0, gbbox.bottom) 430 reg.v1 = max(reg.v1, gbbox.top) 431 reg.objs.append(graphic) 432 break 433 else: 434 regions.append(Region(gbbox.bottom, gbbox.top, graphic)) 435 436 # print(regions) 437 # Coalesce all overlapped graphics objects into full width areas 438 areas = [] 439 ypos = area.top 440 for reg in regions: 441 if ypos - reg.v1 > self._spacing["y_em"]: 442 areas.append((Rectangle(area.left, reg.v1, area.right, ypos), None)) 443 for obj in reg.objs: 444 oarea = obj.bbox.joined(obj.cbbox) if obj.cbbox else obj.bbox 445 areas.append((oarea, obj)) 446 ypos = reg.v0 447 areas.append((Rectangle(area.left, area.bottom, area.right, ypos), None)) 448 else: 449 areas = [(area, None)] 450 return areas 451 452 def objects_in_area(self, area: Rectangle, with_graphics: bool = True) -> list[CharLine | Table | Figure]: 453 """ 454 Find all content objects in this area. 455 456 :param area: area to search for content. 457 :param with_graphics: search for graphics in the area. 458 :return: list of content objects sorted top to bottom. 459 """ 460 self._link_characters() 461 areas = self.graphic_bboxes_in_area(area, with_graphics) 462 objects = [] 463 for narea, obj in areas: 464 if obj is None: 465 objects += self.charlines_in_area(narea) 466 else: 467 oarea = obj.bbox.joined(obj.cbbox) if obj.cbbox else obj.bbox 468 469 def predicate(c): 470 return not obj.bbox.contains(c.origin) 471 472 lines = self.charlines_in_area(oarea, predicate) 473 # print(obj, oarea, lines, [line.content for line in lines]) 474 objects += list(sorted(lines + [obj], key=lambda o: (-o.bbox.y, o.bbox.x))) 475 return objects 476 477 def graphics_in_area(self, area: Rectangle) -> list[Table | Figure]: 478 """ 479 Find all tables and figures in this area. 480 481 :param area: area to search for graphics. 482 :return: list of tables and figures. 483 """ 484 return [] 485 486 def ast_in_area(self, area: Rectangle, with_graphics: bool = True) -> Node: 487 """ 488 Convert the area content into an abstract syntax tree. 489 490 :param area: area to search for content. 491 :param with_graphics: including graphics in the area. 492 :return: An abstract syntax tree including the content formatting. 493 """ 494 return Node("area", obj=area, xpos=int(area.left), page=self) 495 496 @property 497 def content_ast(self) -> list[Node]: 498 """The abstract syntax trees in the content area.""" 499 ast = [] 500 with_graphics = True 501 for area in self._areas["content"]: 502 ast.append(self.ast_in_area(area, with_graphics=with_graphics)) 503 # Add a page node to the first leaf to keep track of where a page starts 504 first_leaf = next((n for n in iter(ast[0].descendants) if n.is_leaf), ast[0]) 505 Node("page", parent=first_leaf, xpos=first_leaf.xpos, number=self.number) 506 return ast 507 508 @property 509 def content_objects(self) -> list[CharLine | Table | Figure]: 510 """All objects in the content areas.""" 511 objs = [] 512 for area in self._areas["content"]: 513 objs.extend(self.objects_in_area(area)) 514 return objs 515 516 @property 517 def content_graphics(self) -> list[Table | Figure]: 518 """All graphics in the content areas.""" 519 objs = [] 520 for area in self._areas["content"]: 521 objs.extend(self.graphics_in_area(area)) 522 return objs 523 524 @property 525 def content_lines(self) -> list[CharLine]: 526 """All lines in the content areas.""" 527 objs = [] 528 for area in self._areas["content"]: 529 objs.extend(self.charlines_in_area(area)) 530 return objs 531 532 @property 533 def content_tables(self) -> list[Table]: 534 """All tables in the content areas.""" 535 return [o for o in self.content_graphics if isinstance(o, Table)] 536 537 @property 538 def content_figures(self) -> list[Figure]: 539 """All figures in the content areas.""" 540 return [o for o in self.content_graphics if isinstance(o, Figure)] 541 542 def __repr__(self) -> str: 543 return f"Page({self.number})"
22class Page(PdfPage): 23 def __init__(self, document, index: int): 24 super().__init__(document, index) 25 self._template = "default" 26 self.is_relevant: bool = True 27 """Is this page relevant for the conversion?""" 28 29 def _unicode_filter(self, code: int) -> int: 30 return code 31 32 @cached_property 33 def _spacing(self) -> dict[str, float]: 34 content = 0.1 35 return { 36 # Horizontal spacing: left->right 37 "x_em": 0.01 * self.width, 38 "x_left": content * self.width, 39 "x_right": (1 - content) * self.width, 40 "x_content": 0.2 * self.width, 41 # Vertical spacing: bottom->top 42 "y_em": 0.01 * self.height, 43 # Max table line thickness 44 "y_tline": 0.005 * self.height, 45 # Max line height distance to detect paragraphs 46 "lh": 0.9, 47 # Max line height distance to detect super-/subscript 48 "sc": 0.3, 49 # Table header cell bold text threshold 50 "th": 0.3, 51 } 52 53 def _line_size(self, line: CharLine) -> str: 54 rsize = line.height 55 if rsize >= 17.5: 56 return "h1" 57 elif rsize >= 15.5: 58 return "h2" 59 elif rsize >= 13.5: 60 return "h3" 61 elif rsize >= 11.4: 62 return "h4" 63 elif rsize >= 8.5: 64 return "n" 65 else: 66 return "fn" 67 68 def _colors(self, color: int) -> str: 69 if 0xFF <= color <= 0xFF: 70 return "black" 71 if 0xFFFFFFFF <= color <= 0xFFFFFFFF: 72 return "white" 73 return "unknown" 74 75 @cached_property 76 def _areas(self) -> dict[str, list[Rectangle] | Rectangle]: 77 content = Rectangle(0.1, 0.1, 0.9, 0.9) 78 areas = {"content": [content]} 79 scaled_areas = {} 80 81 def _s(r): 82 return Rectangle(r.left * self.width, r.bottom * self.height, r.right * self.width, r.top * self.height) 83 84 for name, area in areas.items(): 85 scaled_areas[name] = [_s(r) for r in area] if isinstance(area, list) else _s(area) 86 return scaled_areas 87 88 def _char_properties(self, line, char): 89 cp = { 90 "superscript": False, 91 "subscript": False, 92 "bold": any(frag in char.font for frag in {"Bold"}), 93 "italic": any(frag in char.font for frag in {"Italic", "Oblique"}), 94 "underline": (char.objlink or char.weblink) is not None, 95 "size": round(line.height), 96 "relsize": self._line_size(line), 97 "char": chr(char.unicode), 98 } 99 # Sub- and superscript use a smaller font, characters of the full size 100 # with a shifted baseline are labels next to multi-line text instead 101 if char.height >= 0.95 * line.height: 102 pass 103 elif line.rotation: 104 if char.origin.x < (line.origin - 0.25 * line.height): 105 cp["superscript"] = True 106 elif char.origin.x > (line.origin + 0.15 * line.height): 107 cp["subscript"] = True 108 elif char.origin.y > (line.origin + 0.25 * line.height): 109 cp["superscript"] = True 110 elif char.origin.y < (line.origin - 0.15 * line.height): 111 cp["subscript"] = True 112 return cp 113 114 def text_in_named_area(self, name: str, check_length: bool = True) -> str | None: 115 """ 116 Find all text in the named area. 117 118 :param name: the name of the area(s) to query. 119 :param check_length: assert that the text has a length. 120 :return: the concatenated text of the named area(s) or `None` if area not found. 121 """ 122 if name not in self._areas: 123 return None 124 text = "" 125 areas = self._areas[name] 126 if not isinstance(areas, list): 127 areas = [areas] 128 for area in areas: 129 text += self.text_in_area(area) 130 if check_length: 131 assert text 132 return text 133 134 def charlines_in_area( 135 self, area: Rectangle, predicate: Callable[[Character], bool] = None, rtol: float = None 136 ) -> list[CharLine]: 137 """ 138 Coalesce the characters in the area and predicate into lines. 139 140 1. Every character in the area is filtered by the `predicate`. 141 2. Character orientation is split into horizontal (left->right) and 142 vertical (bottom->top) character lines sorted by x or y position. 143 Lines containing only whitespace are discarded. 144 3. Overlapping character lines are merged into sub- and superscript 145 using `rtol * max(current_line.height, next_line.height)` as the 146 tolerance for checking if the lines overlap. 147 4. The characters in the merged lines are re-sorted by origin. 148 149 :param area: Area to search for characters. 150 :param predicate: Function to discard characters in the area or include all by default. 151 :param rtol: Relative tolerance to separate lines vertically or use `sc` spacing by default. 152 :return: A list of character lines sorted by x or y position. 153 """ 154 if rtol is None: 155 rtol = self._spacing["sc"] 156 # Split all chars into lines based on rounded origin 157 origin_lines_y = defaultdict(list) 158 origin_lines_x = defaultdict(list) 159 for char in self.chars_in_area(area): 160 # Ignore all characters we don't want 161 if predicate is not None and not predicate(char): 162 continue 163 cunicode = self._unicode_filter(char.unicode) 164 if cunicode is None: 165 continue 166 char.unicode = cunicode 167 if char.unicode < 32 and char.unicode not in {0xA}: 168 continue 169 # Ignore characters without width that are not spaces 170 if not char.width and char.unicode not in {0xA, 0xD, 0x20}: 171 _LOGGER.error(f"Unknown char width for {char}: {char.bbox}") 172 # Split up the chars depending on the orientation 173 if 45 < char.rotation <= 135 or 225 < char.rotation <= 315: 174 origin_lines_x[round(char.origin.x, 1)].append(char) 175 elif char.rotation <= 45 or 135 < char.rotation <= 225 or 315 < char.rotation: 176 origin_lines_y[round(char.origin.y, 1)].append(char) 177 else: 178 _LOGGER.error("Unknown char rotation:", char, char.rotation) 179 180 # Convert characters into lines 181 bbox_lines_y = [] 182 for chars in origin_lines_y.values(): 183 # Remove lines with whitespace only 184 if all(c.unicode in {0xA, 0xD, 0x20} for c in chars): 185 continue 186 origin = statistics.fmean(c.origin.y for c in chars) 187 line = CharLine( 188 self, 189 chars, 190 min(c.bbox.bottom for c in chars), 191 origin, 192 max(c.bbox.top for c in chars), 193 max(c.height for c in chars), 194 sort_origin=self.height - origin, 195 ) 196 bbox_lines_y.append(line) 197 # print(line, line.top, line.origin, line.bottom, line.height) 198 bbox_lines = sorted(bbox_lines_y, key=lambda line: line._sort_origin) 199 200 bbox_lines_x = [] 201 for chars in origin_lines_x.values(): 202 # Remove lines with whitespace only 203 if all(c.unicode in {0xA, 0xD, 0x20} for c in chars): 204 continue 205 line = CharLine( 206 self, 207 chars, 208 min(c.bbox.left for c in chars), 209 statistics.fmean(c.origin.x for c in chars), 210 max(c.bbox.right for c in chars), 211 max(c.width for c in chars), 212 270 if sum(c.rotation for c in chars) <= 135 * len(chars) else 90, 213 ) 214 bbox_lines_x.append(line) 215 bbox_lines += sorted(bbox_lines_x, key=lambda line: line._sort_origin) 216 217 if not bbox_lines: 218 return [] 219 220 def _stacked(line0, line1) -> bool: 221 # Characters of the same font size, that overlap along the line by 222 # more than half their width, are printed on top of each other. 223 horizontal = not line0.rotation 224 225 def _spans(line): 226 chars = (c for c in line.chars if c.unicode not in {0x20, 0xA, 0xD}) 227 chars = (c for c in chars if math.isclose(c.height, line.height, rel_tol=0.01)) 228 return [(c.bbox.left, c.bbox.right) if horizontal else (c.bbox.bottom, c.bbox.top) for c in chars] 229 230 spans1 = _spans(line1) 231 for start0, stop0 in _spans(line0): 232 for start1, stop1 in spans1: 233 overlap = min(stop0, stop1) - max(start0, start1) 234 if overlap > 0.5 * min(stop0 - start0, stop1 - start1): 235 return True 236 return False 237 238 def _label(line0, line1) -> bool: 239 # A label of the same font size left of a multi-line text is 240 # vertically centered on the lines, so its baseline is shifted by 241 # more than a continuation of the line would be. 242 if line0.rotation or line1.rotation: 243 return False 244 offset = abs(line0._sort_origin - line1._sort_origin) 245 return offset > rtol * line0.height and ( 246 line0.bbox.right < line1.bbox.left or line1.bbox.right < line0.bbox.left 247 ) 248 249 # Merge lines that have overlapping bbox_lines 250 merged_lines = [] 251 current_line = bbox_lines[0] 252 for next_line in bbox_lines[1:]: 253 height = max(current_line.height, next_line.height) 254 same_height = math.isclose(current_line.height, next_line.height, rel_tol=0.01) 255 # Calculate overlap via normalize origin (increasing with line index) 256 if ( 257 # Horizontal and vertical lines have different sort origins 258 bool(current_line.rotation) == bool(next_line.rotation) 259 and (current_line._sort_origin + rtol * height) > (next_line._sort_origin - rtol * height) 260 # Lines of the same font size printed on top of each other are 261 # separate lines, like the numerator and denominator of a formula 262 and not (same_height and _stacked(current_line, next_line)) 263 and not (same_height and _label(current_line, next_line)) 264 ): 265 # The next line overlaps this one, we merge the shorter line 266 # (typically super- and subscript) into the taller line. Lines 267 # of the same font size continue each other with a slightly 268 # shifted baseline, so we merge them into the longer line. 269 if same_height: 270 use_current = len(current_line.chars) >= len(next_line.chars) 271 else: 272 use_current = current_line.height > next_line.height 273 line = current_line if use_current else next_line 274 current_line = CharLine( 275 self, 276 current_line.chars + next_line.chars, 277 line.bottom, 278 line.origin, 279 line.top, 280 height, 281 line.rotation, 282 sort_origin=line._sort_origin, 283 ) 284 else: 285 # The next line does not overlap the current line 286 merged_lines.append(current_line) 287 current_line = next_line 288 # append last line 289 merged_lines.append(current_line) 290 291 # Prepend labels to the first line of their multi-line text 292 labeled_lines = [] 293 for line in merged_lines: 294 prev = labeled_lines[-1] if labeled_lines else None 295 if ( 296 prev is not None 297 and not line.rotation 298 and math.isclose(prev.height, line.height, rel_tol=0.01) 299 and 0 < line._sort_origin - prev._sort_origin < line.height 300 and line.bbox.right < prev.bbox.left 301 ): 302 # Keep the spaces between the words of the label, but not around it 303 label = sorted((c for c in line.chars if c.unicode not in {0xA, 0xD}), key=lambda c: c.origin.x) 304 label = list_strip(label, lambda c: c.unicode == 0x20) 305 chars = label + [label[-1].generated_space()] + prev.chars 306 labeled_lines[-1] = CharLine( 307 self, chars, prev.bottom, prev.origin, prev.top, prev.height, sort_origin=prev._sort_origin 308 ) 309 else: 310 labeled_lines.append(line) 311 merged_lines = labeled_lines 312 313 # Sort all lines horizontally based on character origin 314 sorted_lines = [] 315 for line in merged_lines: 316 if line.rotation == 90: 317 318 def sort_key(char): 319 if char.unicode in {0xA, 0xD}: 320 return char.tbbox.midpoint.y - 1e9 321 return char.tbbox.midpoint.y 322 elif line.rotation == 270: 323 324 def sort_key(char): 325 if char.unicode in {0xA, 0xD}: 326 return -char.tbbox.midpoint.y + 1e9 327 return -char.tbbox.midpoint.y 328 else: 329 330 def sort_key(char): 331 if char.unicode in {0xA, 0xD}: 332 return char.origin.x + 1e9 333 return char.origin.x 334 335 chars = sorted(line.chars, key=sort_key) 336 if not line.rotation: 337 chars = self._chars_with_bullet_space(chars) 338 chars = self._chars_with_gap_spaces(chars, line.rotation) 339 sorted_lines.append( 340 CharLine( 341 self, 342 chars, 343 line.bottom, 344 line.origin, 345 line.top, 346 line.height, 347 line.rotation, 348 area.left, 349 sort_origin=line._sort_origin, 350 ) 351 ) 352 353 return sorted_lines 354 355 @staticmethod 356 def _chars_with_gap_spaces(chars: list, rotation: int) -> list: 357 # pdfium generates no spaces at the word gaps of fonts without a space 358 # glyph, so we insert them at gaps wider than the typical gap between 359 # the letters of a run of the same font, which may be letter-spaced. 360 def _span(char): 361 if not rotation: 362 return char.bbox.left, char.bbox.right 363 if rotation == 90: 364 return char.bbox.bottom, char.bbox.top 365 return -char.bbox.top, -char.bbox.bottom 366 367 runs, last, key = [], None, None 368 for index, char in enumerate(chars): 369 if char.unicode in {0x20, 0xA, 0xD}: 370 continue 371 start, stop = _span(char) 372 size = round(char.height, 1) 373 # Lines may interleave the characters of several baselines 374 baseline = round(char.origin.x if rotation else char.origin.y, 1) 375 if key != (char.font, size, baseline) or start - last > 0.5 * size: 376 runs.append((char, [])) 377 else: 378 runs[-1][1].append((start - last, index)) 379 key, last = (char.font, size, baseline), stop 380 381 spaces = [] 382 for first, gaps in runs: 383 if not gaps or first.has_space_glyph: 384 continue 385 size = first.height 386 letter_gap = statistics.median(g for g, _ in gaps) 387 spaces += [i for g, i in gaps if g > letter_gap + 0.15 * size and chars[i - 1].unicode != 0x20] 388 for index in sorted(spaces, reverse=True): 389 chars = chars[:index] + [chars[index].generated_space()] + chars[index:] 390 return chars 391 392 @staticmethod 393 def _chars_with_bullet_space(chars: list) -> list: 394 # Newer pdfium does not generate a space for every visual gap anymore, 395 # but the list detection relies on the one between bullet and text. 396 for ii, (bullet, char) in enumerate(zip(chars, chars[1:]), 1): 397 if bullet.unicode == 0x20: 398 continue 399 if ( 400 bullet.char in {"•", "–"} 401 and char.unicode not in {0x20, 0xA, 0xD} 402 and char.origin.x - bullet.bbox.right >= 0.25 * bullet.height 403 ): 404 return chars[:ii] + [char.generated_space()] + chars[ii:] 405 break 406 return chars 407 408 def graphic_bboxes_in_area( 409 self, area: Rectangle, with_graphics: bool = True 410 ) -> list[tuple[Rectangle, Table | Figure | None]]: 411 """ 412 Coalesce the graphics in the area into full width bounding boxes. 413 414 1. Group vertically overlapping graphics. 415 2. Widen the overlapped graphics bounding boxes to the edges of the area. 416 417 :param area: area to search for content. 418 :param with_graphics: search for graphics in the area. 419 :return: list of tuples (bounding box, graphic objects or `None`). 420 """ 421 if with_graphics: 422 graphics = self.graphics_in_area(area) 423 regions = [] 424 # Check if graphics bounding boxes overlap vertically and group them 425 for graphic in sorted(graphics, key=lambda g: (-g.bbox.top, g.bbox.x)): 426 gbbox = graphic.bbox.joined(graphic.cbbox) if graphic.cbbox else graphic.bbox 427 for reg in regions: 428 if reg.overlaps(gbbox.bottom, gbbox.top): 429 # They overlap, so merge them 430 reg.v0 = min(reg.v0, gbbox.bottom) 431 reg.v1 = max(reg.v1, gbbox.top) 432 reg.objs.append(graphic) 433 break 434 else: 435 regions.append(Region(gbbox.bottom, gbbox.top, graphic)) 436 437 # print(regions) 438 # Coalesce all overlapped graphics objects into full width areas 439 areas = [] 440 ypos = area.top 441 for reg in regions: 442 if ypos - reg.v1 > self._spacing["y_em"]: 443 areas.append((Rectangle(area.left, reg.v1, area.right, ypos), None)) 444 for obj in reg.objs: 445 oarea = obj.bbox.joined(obj.cbbox) if obj.cbbox else obj.bbox 446 areas.append((oarea, obj)) 447 ypos = reg.v0 448 areas.append((Rectangle(area.left, area.bottom, area.right, ypos), None)) 449 else: 450 areas = [(area, None)] 451 return areas 452 453 def objects_in_area(self, area: Rectangle, with_graphics: bool = True) -> list[CharLine | Table | Figure]: 454 """ 455 Find all content objects in this area. 456 457 :param area: area to search for content. 458 :param with_graphics: search for graphics in the area. 459 :return: list of content objects sorted top to bottom. 460 """ 461 self._link_characters() 462 areas = self.graphic_bboxes_in_area(area, with_graphics) 463 objects = [] 464 for narea, obj in areas: 465 if obj is None: 466 objects += self.charlines_in_area(narea) 467 else: 468 oarea = obj.bbox.joined(obj.cbbox) if obj.cbbox else obj.bbox 469 470 def predicate(c): 471 return not obj.bbox.contains(c.origin) 472 473 lines = self.charlines_in_area(oarea, predicate) 474 # print(obj, oarea, lines, [line.content for line in lines]) 475 objects += list(sorted(lines + [obj], key=lambda o: (-o.bbox.y, o.bbox.x))) 476 return objects 477 478 def graphics_in_area(self, area: Rectangle) -> list[Table | Figure]: 479 """ 480 Find all tables and figures in this area. 481 482 :param area: area to search for graphics. 483 :return: list of tables and figures. 484 """ 485 return [] 486 487 def ast_in_area(self, area: Rectangle, with_graphics: bool = True) -> Node: 488 """ 489 Convert the area content into an abstract syntax tree. 490 491 :param area: area to search for content. 492 :param with_graphics: including graphics in the area. 493 :return: An abstract syntax tree including the content formatting. 494 """ 495 return Node("area", obj=area, xpos=int(area.left), page=self) 496 497 @property 498 def content_ast(self) -> list[Node]: 499 """The abstract syntax trees in the content area.""" 500 ast = [] 501 with_graphics = True 502 for area in self._areas["content"]: 503 ast.append(self.ast_in_area(area, with_graphics=with_graphics)) 504 # Add a page node to the first leaf to keep track of where a page starts 505 first_leaf = next((n for n in iter(ast[0].descendants) if n.is_leaf), ast[0]) 506 Node("page", parent=first_leaf, xpos=first_leaf.xpos, number=self.number) 507 return ast 508 509 @property 510 def content_objects(self) -> list[CharLine | Table | Figure]: 511 """All objects in the content areas.""" 512 objs = [] 513 for area in self._areas["content"]: 514 objs.extend(self.objects_in_area(area)) 515 return objs 516 517 @property 518 def content_graphics(self) -> list[Table | Figure]: 519 """All graphics in the content areas.""" 520 objs = [] 521 for area in self._areas["content"]: 522 objs.extend(self.graphics_in_area(area)) 523 return objs 524 525 @property 526 def content_lines(self) -> list[CharLine]: 527 """All lines in the content areas.""" 528 objs = [] 529 for area in self._areas["content"]: 530 objs.extend(self.charlines_in_area(area)) 531 return objs 532 533 @property 534 def content_tables(self) -> list[Table]: 535 """All tables in the content areas.""" 536 return [o for o in self.content_graphics if isinstance(o, Table)] 537 538 @property 539 def content_figures(self) -> list[Figure]: 540 """All figures in the content areas.""" 541 return [o for o in self.content_graphics if isinstance(o, Figure)] 542 543 def __repr__(self) -> str: 544 return f"Page({self.number})"
This class provides low-level access to graphics and characters of the page and allows searching for characters in an area instead of just text.
23 def __init__(self, document, index: int): 24 super().__init__(document, index) 25 self._template = "default" 26 self.is_relevant: bool = True 27 """Is this page relevant for the conversion?"""
Parameters
- document: a PDF document.
- index: 0-index page number.
114 def text_in_named_area(self, name: str, check_length: bool = True) -> str | None: 115 """ 116 Find all text in the named area. 117 118 :param name: the name of the area(s) to query. 119 :param check_length: assert that the text has a length. 120 :return: the concatenated text of the named area(s) or `None` if area not found. 121 """ 122 if name not in self._areas: 123 return None 124 text = "" 125 areas = self._areas[name] 126 if not isinstance(areas, list): 127 areas = [areas] 128 for area in areas: 129 text += self.text_in_area(area) 130 if check_length: 131 assert text 132 return text
Find all text in the named area.
Parameters
- name: the name of the area(s) to query.
- check_length: assert that the text has a length.
Returns
the concatenated text of the named area(s) or
Noneif area not found.
134 def charlines_in_area( 135 self, area: Rectangle, predicate: Callable[[Character], bool] = None, rtol: float = None 136 ) -> list[CharLine]: 137 """ 138 Coalesce the characters in the area and predicate into lines. 139 140 1. Every character in the area is filtered by the `predicate`. 141 2. Character orientation is split into horizontal (left->right) and 142 vertical (bottom->top) character lines sorted by x or y position. 143 Lines containing only whitespace are discarded. 144 3. Overlapping character lines are merged into sub- and superscript 145 using `rtol * max(current_line.height, next_line.height)` as the 146 tolerance for checking if the lines overlap. 147 4. The characters in the merged lines are re-sorted by origin. 148 149 :param area: Area to search for characters. 150 :param predicate: Function to discard characters in the area or include all by default. 151 :param rtol: Relative tolerance to separate lines vertically or use `sc` spacing by default. 152 :return: A list of character lines sorted by x or y position. 153 """ 154 if rtol is None: 155 rtol = self._spacing["sc"] 156 # Split all chars into lines based on rounded origin 157 origin_lines_y = defaultdict(list) 158 origin_lines_x = defaultdict(list) 159 for char in self.chars_in_area(area): 160 # Ignore all characters we don't want 161 if predicate is not None and not predicate(char): 162 continue 163 cunicode = self._unicode_filter(char.unicode) 164 if cunicode is None: 165 continue 166 char.unicode = cunicode 167 if char.unicode < 32 and char.unicode not in {0xA}: 168 continue 169 # Ignore characters without width that are not spaces 170 if not char.width and char.unicode not in {0xA, 0xD, 0x20}: 171 _LOGGER.error(f"Unknown char width for {char}: {char.bbox}") 172 # Split up the chars depending on the orientation 173 if 45 < char.rotation <= 135 or 225 < char.rotation <= 315: 174 origin_lines_x[round(char.origin.x, 1)].append(char) 175 elif char.rotation <= 45 or 135 < char.rotation <= 225 or 315 < char.rotation: 176 origin_lines_y[round(char.origin.y, 1)].append(char) 177 else: 178 _LOGGER.error("Unknown char rotation:", char, char.rotation) 179 180 # Convert characters into lines 181 bbox_lines_y = [] 182 for chars in origin_lines_y.values(): 183 # Remove lines with whitespace only 184 if all(c.unicode in {0xA, 0xD, 0x20} for c in chars): 185 continue 186 origin = statistics.fmean(c.origin.y for c in chars) 187 line = CharLine( 188 self, 189 chars, 190 min(c.bbox.bottom for c in chars), 191 origin, 192 max(c.bbox.top for c in chars), 193 max(c.height for c in chars), 194 sort_origin=self.height - origin, 195 ) 196 bbox_lines_y.append(line) 197 # print(line, line.top, line.origin, line.bottom, line.height) 198 bbox_lines = sorted(bbox_lines_y, key=lambda line: line._sort_origin) 199 200 bbox_lines_x = [] 201 for chars in origin_lines_x.values(): 202 # Remove lines with whitespace only 203 if all(c.unicode in {0xA, 0xD, 0x20} for c in chars): 204 continue 205 line = CharLine( 206 self, 207 chars, 208 min(c.bbox.left for c in chars), 209 statistics.fmean(c.origin.x for c in chars), 210 max(c.bbox.right for c in chars), 211 max(c.width for c in chars), 212 270 if sum(c.rotation for c in chars) <= 135 * len(chars) else 90, 213 ) 214 bbox_lines_x.append(line) 215 bbox_lines += sorted(bbox_lines_x, key=lambda line: line._sort_origin) 216 217 if not bbox_lines: 218 return [] 219 220 def _stacked(line0, line1) -> bool: 221 # Characters of the same font size, that overlap along the line by 222 # more than half their width, are printed on top of each other. 223 horizontal = not line0.rotation 224 225 def _spans(line): 226 chars = (c for c in line.chars if c.unicode not in {0x20, 0xA, 0xD}) 227 chars = (c for c in chars if math.isclose(c.height, line.height, rel_tol=0.01)) 228 return [(c.bbox.left, c.bbox.right) if horizontal else (c.bbox.bottom, c.bbox.top) for c in chars] 229 230 spans1 = _spans(line1) 231 for start0, stop0 in _spans(line0): 232 for start1, stop1 in spans1: 233 overlap = min(stop0, stop1) - max(start0, start1) 234 if overlap > 0.5 * min(stop0 - start0, stop1 - start1): 235 return True 236 return False 237 238 def _label(line0, line1) -> bool: 239 # A label of the same font size left of a multi-line text is 240 # vertically centered on the lines, so its baseline is shifted by 241 # more than a continuation of the line would be. 242 if line0.rotation or line1.rotation: 243 return False 244 offset = abs(line0._sort_origin - line1._sort_origin) 245 return offset > rtol * line0.height and ( 246 line0.bbox.right < line1.bbox.left or line1.bbox.right < line0.bbox.left 247 ) 248 249 # Merge lines that have overlapping bbox_lines 250 merged_lines = [] 251 current_line = bbox_lines[0] 252 for next_line in bbox_lines[1:]: 253 height = max(current_line.height, next_line.height) 254 same_height = math.isclose(current_line.height, next_line.height, rel_tol=0.01) 255 # Calculate overlap via normalize origin (increasing with line index) 256 if ( 257 # Horizontal and vertical lines have different sort origins 258 bool(current_line.rotation) == bool(next_line.rotation) 259 and (current_line._sort_origin + rtol * height) > (next_line._sort_origin - rtol * height) 260 # Lines of the same font size printed on top of each other are 261 # separate lines, like the numerator and denominator of a formula 262 and not (same_height and _stacked(current_line, next_line)) 263 and not (same_height and _label(current_line, next_line)) 264 ): 265 # The next line overlaps this one, we merge the shorter line 266 # (typically super- and subscript) into the taller line. Lines 267 # of the same font size continue each other with a slightly 268 # shifted baseline, so we merge them into the longer line. 269 if same_height: 270 use_current = len(current_line.chars) >= len(next_line.chars) 271 else: 272 use_current = current_line.height > next_line.height 273 line = current_line if use_current else next_line 274 current_line = CharLine( 275 self, 276 current_line.chars + next_line.chars, 277 line.bottom, 278 line.origin, 279 line.top, 280 height, 281 line.rotation, 282 sort_origin=line._sort_origin, 283 ) 284 else: 285 # The next line does not overlap the current line 286 merged_lines.append(current_line) 287 current_line = next_line 288 # append last line 289 merged_lines.append(current_line) 290 291 # Prepend labels to the first line of their multi-line text 292 labeled_lines = [] 293 for line in merged_lines: 294 prev = labeled_lines[-1] if labeled_lines else None 295 if ( 296 prev is not None 297 and not line.rotation 298 and math.isclose(prev.height, line.height, rel_tol=0.01) 299 and 0 < line._sort_origin - prev._sort_origin < line.height 300 and line.bbox.right < prev.bbox.left 301 ): 302 # Keep the spaces between the words of the label, but not around it 303 label = sorted((c for c in line.chars if c.unicode not in {0xA, 0xD}), key=lambda c: c.origin.x) 304 label = list_strip(label, lambda c: c.unicode == 0x20) 305 chars = label + [label[-1].generated_space()] + prev.chars 306 labeled_lines[-1] = CharLine( 307 self, chars, prev.bottom, prev.origin, prev.top, prev.height, sort_origin=prev._sort_origin 308 ) 309 else: 310 labeled_lines.append(line) 311 merged_lines = labeled_lines 312 313 # Sort all lines horizontally based on character origin 314 sorted_lines = [] 315 for line in merged_lines: 316 if line.rotation == 90: 317 318 def sort_key(char): 319 if char.unicode in {0xA, 0xD}: 320 return char.tbbox.midpoint.y - 1e9 321 return char.tbbox.midpoint.y 322 elif line.rotation == 270: 323 324 def sort_key(char): 325 if char.unicode in {0xA, 0xD}: 326 return -char.tbbox.midpoint.y + 1e9 327 return -char.tbbox.midpoint.y 328 else: 329 330 def sort_key(char): 331 if char.unicode in {0xA, 0xD}: 332 return char.origin.x + 1e9 333 return char.origin.x 334 335 chars = sorted(line.chars, key=sort_key) 336 if not line.rotation: 337 chars = self._chars_with_bullet_space(chars) 338 chars = self._chars_with_gap_spaces(chars, line.rotation) 339 sorted_lines.append( 340 CharLine( 341 self, 342 chars, 343 line.bottom, 344 line.origin, 345 line.top, 346 line.height, 347 line.rotation, 348 area.left, 349 sort_origin=line._sort_origin, 350 ) 351 ) 352 353 return sorted_lines
Coalesce the characters in the area and predicate into lines.
- Every character in the area is filtered by the
predicate. - Character orientation is split into horizontal (left->right) and vertical (bottom->top) character lines sorted by x or y position. Lines containing only whitespace are discarded.
- Overlapping character lines are merged into sub- and superscript
using
rtol * max(current_line.height, next_line.height)as the tolerance for checking if the lines overlap. - The characters in the merged lines are re-sorted by origin.
Parameters
- area: Area to search for characters.
- predicate: Function to discard characters in the area or include all by default.
- rtol: Relative tolerance to separate lines vertically or use
scspacing by default.
Returns
A list of character lines sorted by x or y position.
408 def graphic_bboxes_in_area( 409 self, area: Rectangle, with_graphics: bool = True 410 ) -> list[tuple[Rectangle, Table | Figure | None]]: 411 """ 412 Coalesce the graphics in the area into full width bounding boxes. 413 414 1. Group vertically overlapping graphics. 415 2. Widen the overlapped graphics bounding boxes to the edges of the area. 416 417 :param area: area to search for content. 418 :param with_graphics: search for graphics in the area. 419 :return: list of tuples (bounding box, graphic objects or `None`). 420 """ 421 if with_graphics: 422 graphics = self.graphics_in_area(area) 423 regions = [] 424 # Check if graphics bounding boxes overlap vertically and group them 425 for graphic in sorted(graphics, key=lambda g: (-g.bbox.top, g.bbox.x)): 426 gbbox = graphic.bbox.joined(graphic.cbbox) if graphic.cbbox else graphic.bbox 427 for reg in regions: 428 if reg.overlaps(gbbox.bottom, gbbox.top): 429 # They overlap, so merge them 430 reg.v0 = min(reg.v0, gbbox.bottom) 431 reg.v1 = max(reg.v1, gbbox.top) 432 reg.objs.append(graphic) 433 break 434 else: 435 regions.append(Region(gbbox.bottom, gbbox.top, graphic)) 436 437 # print(regions) 438 # Coalesce all overlapped graphics objects into full width areas 439 areas = [] 440 ypos = area.top 441 for reg in regions: 442 if ypos - reg.v1 > self._spacing["y_em"]: 443 areas.append((Rectangle(area.left, reg.v1, area.right, ypos), None)) 444 for obj in reg.objs: 445 oarea = obj.bbox.joined(obj.cbbox) if obj.cbbox else obj.bbox 446 areas.append((oarea, obj)) 447 ypos = reg.v0 448 areas.append((Rectangle(area.left, area.bottom, area.right, ypos), None)) 449 else: 450 areas = [(area, None)] 451 return areas
Coalesce the graphics in the area into full width bounding boxes.
- Group vertically overlapping graphics.
- Widen the overlapped graphics bounding boxes to the edges of the area.
Parameters
- area: area to search for content.
- with_graphics: search for graphics in the area.
Returns
list of tuples (bounding box, graphic objects or
None).
453 def objects_in_area(self, area: Rectangle, with_graphics: bool = True) -> list[CharLine | Table | Figure]: 454 """ 455 Find all content objects in this area. 456 457 :param area: area to search for content. 458 :param with_graphics: search for graphics in the area. 459 :return: list of content objects sorted top to bottom. 460 """ 461 self._link_characters() 462 areas = self.graphic_bboxes_in_area(area, with_graphics) 463 objects = [] 464 for narea, obj in areas: 465 if obj is None: 466 objects += self.charlines_in_area(narea) 467 else: 468 oarea = obj.bbox.joined(obj.cbbox) if obj.cbbox else obj.bbox 469 470 def predicate(c): 471 return not obj.bbox.contains(c.origin) 472 473 lines = self.charlines_in_area(oarea, predicate) 474 # print(obj, oarea, lines, [line.content for line in lines]) 475 objects += list(sorted(lines + [obj], key=lambda o: (-o.bbox.y, o.bbox.x))) 476 return objects
Find all content objects in this area.
Parameters
- area: area to search for content.
- with_graphics: search for graphics in the area.
Returns
list of content objects sorted top to bottom.
478 def graphics_in_area(self, area: Rectangle) -> list[Table | Figure]: 479 """ 480 Find all tables and figures in this area. 481 482 :param area: area to search for graphics. 483 :return: list of tables and figures. 484 """ 485 return []
Find all tables and figures in this area.
Parameters
- area: area to search for graphics.
Returns
list of tables and figures.
487 def ast_in_area(self, area: Rectangle, with_graphics: bool = True) -> Node: 488 """ 489 Convert the area content into an abstract syntax tree. 490 491 :param area: area to search for content. 492 :param with_graphics: including graphics in the area. 493 :return: An abstract syntax tree including the content formatting. 494 """ 495 return Node("area", obj=area, xpos=int(area.left), page=self)
Convert the area content into an abstract syntax tree.
Parameters
- area: area to search for content.
- with_graphics: including graphics in the area.
Returns
An abstract syntax tree including the content formatting.
497 @property 498 def content_ast(self) -> list[Node]: 499 """The abstract syntax trees in the content area.""" 500 ast = [] 501 with_graphics = True 502 for area in self._areas["content"]: 503 ast.append(self.ast_in_area(area, with_graphics=with_graphics)) 504 # Add a page node to the first leaf to keep track of where a page starts 505 first_leaf = next((n for n in iter(ast[0].descendants) if n.is_leaf), ast[0]) 506 Node("page", parent=first_leaf, xpos=first_leaf.xpos, number=self.number) 507 return ast
The abstract syntax trees in the content area.
509 @property 510 def content_objects(self) -> list[CharLine | Table | Figure]: 511 """All objects in the content areas.""" 512 objs = [] 513 for area in self._areas["content"]: 514 objs.extend(self.objects_in_area(area)) 515 return objs
All objects in the content areas.
517 @property 518 def content_graphics(self) -> list[Table | Figure]: 519 """All graphics in the content areas.""" 520 objs = [] 521 for area in self._areas["content"]: 522 objs.extend(self.graphics_in_area(area)) 523 return objs
All graphics in the content areas.
525 @property 526 def content_lines(self) -> list[CharLine]: 527 """All lines in the content areas.""" 528 objs = [] 529 for area in self._areas["content"]: 530 objs.extend(self.charlines_in_area(area)) 531 return objs
All lines in the content areas.
533 @property 534 def content_tables(self) -> list[Table]: 535 """All tables in the content areas.""" 536 return [o for o in self.content_graphics if isinstance(o, Table)]
All tables in the content areas.
538 @property 539 def content_figures(self) -> list[Figure]: 540 """All figures in the content areas.""" 541 return [o for o in self.content_graphics if isinstance(o, Figure)]
All figures in the content areas.
Inherited Members
- modm_data.pdf.page.Page
- index
- number
- label
- width
- height
- rotation
- bbox
- char_count
- char
- chars
- objlinks
- weblinks
- chars_in_area
- text_in_area
- structures
- find
- paths
- images
- graphic_clusters
- pypdfium2._helpers.page.PdfPage
- raw
- formenv
- parent
- get_width
- get_height
- get_size
- get_rotation
- set_rotation
- get_mediabox
- set_mediabox
- get_cropbox
- set_cropbox
- get_bleedbox
- set_bleedbox
- get_trimbox
- set_trimbox
- get_artbox
- set_artbox
- get_bbox
- get_textpage
- insert_obj
- remove_obj
- gen_content
- get_objects
- flatten
- render
- pypdfium2.internal.bases.AutoCloseable
- close