PyMuPDF 1.26.6__cp310-abi3-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. fitz/__init__.py +13 -0
  2. fitz/table.py +2 -0
  3. fitz/utils.py +2 -0
  4. pymupdf/__init__.py +25899 -0
  5. pymupdf/__main__.py +1140 -0
  6. pymupdf/_apply_pages.py +253 -0
  7. pymupdf/_build.py +8 -0
  8. pymupdf/_extra.pyd +0 -0
  9. pymupdf/_mupdf.pyd +0 -0
  10. pymupdf/_wxcolors.py +562 -0
  11. pymupdf/extra.py +220 -0
  12. pymupdf/mupdf-devel/include/mupdf/classes.h +19999 -0
  13. pymupdf/mupdf-devel/include/mupdf/classes2.h +12244 -0
  14. pymupdf/mupdf-devel/include/mupdf/exceptions.h +118 -0
  15. pymupdf/mupdf-devel/include/mupdf/extra.h +189 -0
  16. pymupdf/mupdf-devel/include/mupdf/fitz/archive.h +444 -0
  17. pymupdf/mupdf-devel/include/mupdf/fitz/band-writer.h +117 -0
  18. pymupdf/mupdf-devel/include/mupdf/fitz/barcode.h +138 -0
  19. pymupdf/mupdf-devel/include/mupdf/fitz/bidi.h +90 -0
  20. pymupdf/mupdf-devel/include/mupdf/fitz/bitmap.h +188 -0
  21. pymupdf/mupdf-devel/include/mupdf/fitz/buffer.h +250 -0
  22. pymupdf/mupdf-devel/include/mupdf/fitz/color.h +433 -0
  23. pymupdf/mupdf-devel/include/mupdf/fitz/compress.h +132 -0
  24. pymupdf/mupdf-devel/include/mupdf/fitz/compressed-buffer.h +194 -0
  25. pymupdf/mupdf-devel/include/mupdf/fitz/config.h +302 -0
  26. pymupdf/mupdf-devel/include/mupdf/fitz/context.h +1064 -0
  27. pymupdf/mupdf-devel/include/mupdf/fitz/crypt.h +270 -0
  28. pymupdf/mupdf-devel/include/mupdf/fitz/deskew.h +46 -0
  29. pymupdf/mupdf-devel/include/mupdf/fitz/device.h +652 -0
  30. pymupdf/mupdf-devel/include/mupdf/fitz/display-list.h +142 -0
  31. pymupdf/mupdf-devel/include/mupdf/fitz/document.h +1103 -0
  32. pymupdf/mupdf-devel/include/mupdf/fitz/export.h +52 -0
  33. pymupdf/mupdf-devel/include/mupdf/fitz/filter.h +269 -0
  34. pymupdf/mupdf-devel/include/mupdf/fitz/font.h +858 -0
  35. pymupdf/mupdf-devel/include/mupdf/fitz/geometry.h +885 -0
  36. pymupdf/mupdf-devel/include/mupdf/fitz/getopt.h +134 -0
  37. pymupdf/mupdf-devel/include/mupdf/fitz/glyph-cache.h +96 -0
  38. pymupdf/mupdf-devel/include/mupdf/fitz/glyph.h +81 -0
  39. pymupdf/mupdf-devel/include/mupdf/fitz/hash.h +126 -0
  40. pymupdf/mupdf-devel/include/mupdf/fitz/heap-imp.h +190 -0
  41. pymupdf/mupdf-devel/include/mupdf/fitz/heap.h +143 -0
  42. pymupdf/mupdf-devel/include/mupdf/fitz/image.h +451 -0
  43. pymupdf/mupdf-devel/include/mupdf/fitz/json.h +98 -0
  44. pymupdf/mupdf-devel/include/mupdf/fitz/link.h +130 -0
  45. pymupdf/mupdf-devel/include/mupdf/fitz/log.h +61 -0
  46. pymupdf/mupdf-devel/include/mupdf/fitz/outline.h +254 -0
  47. pymupdf/mupdf-devel/include/mupdf/fitz/output-svg.h +64 -0
  48. pymupdf/mupdf-devel/include/mupdf/fitz/output.h +448 -0
  49. pymupdf/mupdf-devel/include/mupdf/fitz/path.h +477 -0
  50. pymupdf/mupdf-devel/include/mupdf/fitz/pixmap.h +516 -0
  51. pymupdf/mupdf-devel/include/mupdf/fitz/pool.h +102 -0
  52. pymupdf/mupdf-devel/include/mupdf/fitz/separation.h +138 -0
  53. pymupdf/mupdf-devel/include/mupdf/fitz/shade.h +233 -0
  54. pymupdf/mupdf-devel/include/mupdf/fitz/store.h +465 -0
  55. pymupdf/mupdf-devel/include/mupdf/fitz/story-writer.h +209 -0
  56. pymupdf/mupdf-devel/include/mupdf/fitz/story.h +232 -0
  57. pymupdf/mupdf-devel/include/mupdf/fitz/stream.h +646 -0
  58. pymupdf/mupdf-devel/include/mupdf/fitz/string-util.h +335 -0
  59. pymupdf/mupdf-devel/include/mupdf/fitz/structured-text.h +924 -0
  60. pymupdf/mupdf-devel/include/mupdf/fitz/system.h +495 -0
  61. pymupdf/mupdf-devel/include/mupdf/fitz/text.h +210 -0
  62. pymupdf/mupdf-devel/include/mupdf/fitz/track-usage.h +57 -0
  63. pymupdf/mupdf-devel/include/mupdf/fitz/transition.h +76 -0
  64. pymupdf/mupdf-devel/include/mupdf/fitz/tree.h +62 -0
  65. pymupdf/mupdf-devel/include/mupdf/fitz/types.h +41 -0
  66. pymupdf/mupdf-devel/include/mupdf/fitz/util.h +159 -0
  67. pymupdf/mupdf-devel/include/mupdf/fitz/version.h +31 -0
  68. pymupdf/mupdf-devel/include/mupdf/fitz/write-pixmap.h +507 -0
  69. pymupdf/mupdf-devel/include/mupdf/fitz/writer.h +269 -0
  70. pymupdf/mupdf-devel/include/mupdf/fitz/xml.h +435 -0
  71. pymupdf/mupdf-devel/include/mupdf/fitz.h +101 -0
  72. pymupdf/mupdf-devel/include/mupdf/functions.h +13819 -0
  73. pymupdf/mupdf-devel/include/mupdf/helpers/mu-office-lib.h +666 -0
  74. pymupdf/mupdf-devel/include/mupdf/helpers/mu-threads.h +280 -0
  75. pymupdf/mupdf-devel/include/mupdf/helpers/pkcs7-openssl.h +48 -0
  76. pymupdf/mupdf-devel/include/mupdf/html.h +53 -0
  77. pymupdf/mupdf-devel/include/mupdf/internal.h +29 -0
  78. pymupdf/mupdf-devel/include/mupdf/memento.h +425 -0
  79. pymupdf/mupdf-devel/include/mupdf/pdf/annot.h +1015 -0
  80. pymupdf/mupdf-devel/include/mupdf/pdf/clean.h +68 -0
  81. pymupdf/mupdf-devel/include/mupdf/pdf/cmap.h +145 -0
  82. pymupdf/mupdf-devel/include/mupdf/pdf/crypt.h +108 -0
  83. pymupdf/mupdf-devel/include/mupdf/pdf/document.h +902 -0
  84. pymupdf/mupdf-devel/include/mupdf/pdf/event.h +167 -0
  85. pymupdf/mupdf-devel/include/mupdf/pdf/font.h +168 -0
  86. pymupdf/mupdf-devel/include/mupdf/pdf/form.h +381 -0
  87. pymupdf/mupdf-devel/include/mupdf/pdf/image-rewriter.h +78 -0
  88. pymupdf/mupdf-devel/include/mupdf/pdf/interpret.h +518 -0
  89. pymupdf/mupdf-devel/include/mupdf/pdf/javascript.h +43 -0
  90. pymupdf/mupdf-devel/include/mupdf/pdf/name-table.h +621 -0
  91. pymupdf/mupdf-devel/include/mupdf/pdf/object.h +445 -0
  92. pymupdf/mupdf-devel/include/mupdf/pdf/page.h +327 -0
  93. pymupdf/mupdf-devel/include/mupdf/pdf/parse.h +61 -0
  94. pymupdf/mupdf-devel/include/mupdf/pdf/recolor.h +49 -0
  95. pymupdf/mupdf-devel/include/mupdf/pdf/resource.h +144 -0
  96. pymupdf/mupdf-devel/include/mupdf/pdf/xref.h +300 -0
  97. pymupdf/mupdf-devel/include/mupdf/pdf/zugferd.h +49 -0
  98. pymupdf/mupdf-devel/include/mupdf/pdf.h +59 -0
  99. pymupdf/mupdf-devel/include/mupdf/ucdn.h +464 -0
  100. pymupdf/mupdf-devel/lib/libmuthreads.lib +0 -0
  101. pymupdf/mupdf-devel/lib/mupdfcpp64.lib +0 -0
  102. pymupdf/mupdf.py +63667 -0
  103. pymupdf/mupdfcpp64.dll +0 -0
  104. pymupdf/pymupdf.py +2 -0
  105. pymupdf/table.py +2697 -0
  106. pymupdf/utils.py +1169 -0
  107. pymupdf-1.26.6.dist-info/COPYING +1 -0
  108. pymupdf-1.26.6.dist-info/METADATA +83 -0
  109. pymupdf-1.26.6.dist-info/README.md +60 -0
  110. pymupdf-1.26.6.dist-info/RECORD +112 -0
  111. pymupdf-1.26.6.dist-info/WHEEL +4 -0
  112. pymupdf-1.26.6.dist-info/entry_points.txt +3 -0
pymupdf/table.py ADDED
@@ -0,0 +1,2697 @@
1
+ """
2
+ Copyright (C) 2023 Artifex Software, Inc.
3
+
4
+ This file is part of PyMuPDF.
5
+
6
+ PyMuPDF is free software: you can redistribute it and/or modify it under the
7
+ terms of the GNU Affero General Public License as published by the Free
8
+ Software Foundation, either version 3 of the License, or (at your option)
9
+ any later version.
10
+
11
+ PyMuPDF is distributed in the hope that it will be useful, but WITHOUT ANY
12
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
13
+ FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
14
+ details.
15
+
16
+ You should have received a copy of the GNU Affero General Public License
17
+ along with MuPDF. If not, see <https://www.gnu.org/licenses/agpl-3.0.en.html>
18
+
19
+ Alternative licensing terms are available from the licensor.
20
+ For commercial licensing, see <https://www.artifex.com/> or contact
21
+ Artifex Software, Inc., 39 Mesa Street, Suite 108A, San Francisco,
22
+ CA 94129, USA, for further information.
23
+
24
+ ---------------------------------------------------------------------
25
+ Portions of this code have been ported from pdfplumber, see
26
+ https://pypi.org/project/pdfplumber/.
27
+
28
+ The ported code is under the following MIT license:
29
+
30
+ ---------------------------------------------------------------------
31
+ The MIT License (MIT)
32
+
33
+ Copyright (c) 2015, Jeremy Singer-Vine
34
+
35
+ Permission is hereby granted, free of charge, to any person obtaining a copy
36
+ of this software and associated documentation files (the "Software"), to deal
37
+ in the Software without restriction, including without limitation the rights
38
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
39
+ copies of the Software, and to permit persons to whom the Software is
40
+ furnished to do so, subject to the following conditions:
41
+
42
+ The above copyright notice and this permission notice shall be included in all
43
+ copies or substantial portions of the Software.
44
+
45
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
46
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
47
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
48
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
49
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
50
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
51
+ SOFTWARE.
52
+ ---------------------------------------------------------------------
53
+ Also see here: https://github.com/jsvine/pdfplumber/blob/stable/LICENSE.txt
54
+ ---------------------------------------------------------------------
55
+
56
+ The porting mainly pertains to files "table.py" and relevant parts of
57
+ "utils/text.py" within pdfplumber's repository on Github.
58
+ With respect to "text.py", we have removed functions or features that are not
59
+ used by table processing. Examples are:
60
+
61
+ * the text search function
62
+ * simple text extraction
63
+ * text extraction by lines
64
+
65
+ Original pdfplumber code does neither detect, nor identify table headers.
66
+ This PyMuPDF port adds respective code to the 'Table' class as method '_get_header'.
67
+ This is implemented as new class TableHeader with the properties:
68
+ * bbox: A tuple for the header's bbox
69
+ * cells: A tuple for each bbox of a column header
70
+ * names: A list of strings with column header text
71
+ * external: A bool indicating whether the header is outside the table cells.
72
+
73
+ """
74
+
75
+ import inspect
76
+ import itertools
77
+ import string
78
+ import html
79
+ from collections.abc import Sequence
80
+ from dataclasses import dataclass
81
+ from operator import itemgetter
82
+ import weakref
83
+ import pymupdf
84
+ from pymupdf import mupdf
85
+
86
+ # -------------------------------------------------------------------
87
+ # Start of PyMuPDF interface code
88
+ # -------------------------------------------------------------------
89
+
90
+ EDGES = [] # vector graphics from PyMuPDF
91
+ CHARS = [] # text characters from PyMuPDF
92
+ TEXTPAGE = None
93
+ TEXT_BOLD = mupdf.FZ_STEXT_BOLD
94
+ TEXT_STRIKEOUT = mupdf.FZ_STEXT_STRIKEOUT
95
+ FLAGS = (
96
+ 0
97
+ | pymupdf.TEXTFLAGS_TEXT
98
+ | pymupdf.TEXT_COLLECT_STYLES
99
+ | pymupdf.TEXT_ACCURATE_BBOXES
100
+ | pymupdf.TEXT_MEDIABOX_CLIP
101
+ )
102
+ # needed by mupdf function fz_find_table_within_bounds().
103
+ TABLE_DETECTOR_FLAGS = (
104
+ 0
105
+ | pymupdf.TEXT_ACCURATE_BBOXES
106
+ | pymupdf.TEXT_SEGMENT
107
+ | pymupdf.TEXT_COLLECT_VECTORS
108
+ | pymupdf.TEXT_MEDIABOX_CLIP
109
+ )
110
+ white_spaces = set(string.whitespace) # for checking white space only cells
111
+
112
+
113
+ def _iou(r1, r2):
114
+ """Compute intersection over union of two rectangles."""
115
+ ix = max(0, min(r1[2], r2[2]) - max(r1[0], r2[0]))
116
+ iy = max(0, min(r1[3], r2[3]) - max(r1[1], r2[1]))
117
+ intersection = ix * iy # intersection area
118
+ if not intersection:
119
+ return 0
120
+ area1 = (r1[2] - r1[0]) * (r1[3] - r1[1])
121
+ area2 = (r2[2] - r2[0]) * (r2[3] - r2[1])
122
+ return intersection / (area1 + area2 - intersection)
123
+
124
+
125
+ def intersects_words_h(bbox, y, word_rects) -> bool:
126
+ """Check whether any of the words in bbox are cut through by
127
+ horizontal line y.
128
+ """
129
+ return any(r.y0 < y < r.y1 for r in word_rects if r in bbox)
130
+
131
+
132
+ def get_table_dict_from_rect(textpage, rect):
133
+ """Extract MuPDF table structure information from a given rectangle."""
134
+ table_dict = {}
135
+ pymupdf.extra.make_table_dict(textpage.this.m_internal, table_dict, rect)
136
+ return table_dict
137
+
138
+
139
+ def make_table_from_bbox(textpage, word_rects, rect):
140
+ """Detect table structure within a given rectangle."""
141
+ cells = [] # table cells as (x0,y0,x1,y1) tuples
142
+
143
+ # calls fz_find_table_within_bounds
144
+ block = get_table_dict_from_rect(textpage, rect)
145
+ # No table structure found if not a grid block
146
+ if block.get("type") != mupdf.FZ_STEXT_BLOCK_GRID:
147
+ return cells
148
+ bbox = pymupdf.Rect(block["bbox"]) # resulting table bbox
149
+
150
+ # lists of (pos,uncertainty) tuples
151
+ xpos = sorted(block["xpos"], key=lambda x: x[0])
152
+ ypos = sorted(block["ypos"], key=lambda y: y[0])
153
+
154
+ # maximum uncertainties in x and y directions
155
+ xmaxu, ymaxu = block["max_uncertain"]
156
+
157
+ # Modify ypos to remove uncertain positions, and y positions
158
+ # that cut through words.
159
+ nypos = []
160
+ for y, yunc in ypos:
161
+ if yunc > 0: # allow no uncertain y values
162
+ continue
163
+ if intersects_words_h(bbox, y, word_rects):
164
+ continue # allow no y that cuts through words
165
+ if nypos and (y - nypos[-1] < 3):
166
+ nypos[-1] = y # snap close positions
167
+ else:
168
+ nypos.append(y)
169
+
170
+ # New max y uncertainty: 35% of remaining y positions.
171
+ # Omit x positions that intersect too many words, otherwise
172
+ # only remove x for the affected cells.
173
+ ymaxu = max(0, round((len(nypos) - 2) * 0.35))
174
+
175
+ # Exclude x positions with too high uncertainty
176
+ # (we allow more uncertainty in x direction)
177
+ nxpos = [x[0] for x in xpos if x[1] <= ymaxu]
178
+ if bbox.x1 > nxpos[-1] + 3:
179
+ nxpos.append(bbox.x1) # ensure right table border
180
+
181
+ # Compose cells from the remaining x and y positions.
182
+ for i in range(len(nypos) - 1):
183
+ row_box = pymupdf.Rect(bbox.x0, nypos[i], bbox.x1, nypos[i + 1])
184
+ # Sub-select words in this row and sort them by left coordinate
185
+ row_words = sorted([r for r in word_rects if r in row_box], key=lambda r: r.x0)
186
+ # Sub-select x values that do not cut through words
187
+ this_xpos = [x for x in nxpos if not any(r.x0 < x < r.x1 for r in row_words)]
188
+ for j in range(len(this_xpos) - 1):
189
+ cell = pymupdf.Rect(this_xpos[j], nypos[i], this_xpos[j + 1], nypos[i + 1])
190
+ if not cell.is_empty: # valid cell
191
+ cells.append(tuple(cell))
192
+ # Add new table to TableFinder tables
193
+ return cells
194
+
195
+
196
+ def extract_cells(textpage, cell, markdown=False):
197
+ """Extract text from a rect-like 'cell' as plain or MD styled text.
198
+
199
+ This function should ultimately be used to extract text from a table cell.
200
+ Markdown output will only work correctly if extraction flag bit
201
+ TEXT_COLLECT_STYLES is set.
202
+
203
+ Args:
204
+ textpage: A PyMuPDF TextPage object. Must have been created with
205
+ TEXTFLAGS_TEXT | TEXT_COLLECT_STYLES.
206
+ cell: A tuple (x0, y0, x1, y1) defining the cell's bbox.
207
+ markdown: If True, return text formatted for Markdown.
208
+
209
+ Returns:
210
+ A string with the text extracted from the cell.
211
+ """
212
+ text = ""
213
+ for block in textpage.extractRAWDICT()["blocks"]:
214
+ if block["type"] != 0:
215
+ continue
216
+ block_bbox = block["bbox"]
217
+ if (
218
+ 0
219
+ or block_bbox[0] > cell[2]
220
+ or block_bbox[2] < cell[0]
221
+ or block_bbox[1] > cell[3]
222
+ or block_bbox[3] < cell[1]
223
+ ):
224
+ continue # skip block outside cell
225
+ for line in block["lines"]:
226
+ lbbox = line["bbox"]
227
+ if (
228
+ 0
229
+ or lbbox[0] > cell[2]
230
+ or lbbox[2] < cell[0]
231
+ or lbbox[1] > cell[3]
232
+ or lbbox[3] < cell[1]
233
+ ):
234
+ continue # skip line outside cell
235
+
236
+ if text: # must be a new line in the cell
237
+ text += "<br>" if markdown else "\n"
238
+
239
+ # strikeout detection only works with horizontal text
240
+ horizontal = line["dir"] == (0, 1) or line["dir"] == (1, 0)
241
+
242
+ for span in line["spans"]:
243
+ sbbox = span["bbox"]
244
+ if (
245
+ 0
246
+ or sbbox[0] > cell[2]
247
+ or sbbox[2] < cell[0]
248
+ or sbbox[1] > cell[3]
249
+ or sbbox[3] < cell[1]
250
+ ):
251
+ continue # skip spans outside cell
252
+
253
+ # only include chars with more than 50% bbox overlap
254
+ span_text = ""
255
+ for char in span["chars"]:
256
+ this_char = char["c"]
257
+ bbox = pymupdf.Rect(char["bbox"])
258
+ if abs(bbox & cell) > 0.5 * abs(bbox):
259
+ span_text += this_char
260
+ elif this_char in white_spaces:
261
+ span_text += " "
262
+
263
+ if not span_text:
264
+ continue # skip empty span
265
+
266
+ if not markdown: # no MD styling
267
+ text += span_text
268
+ continue
269
+
270
+ prefix = ""
271
+ suffix = ""
272
+ if horizontal and span["char_flags"] & TEXT_STRIKEOUT:
273
+ prefix += "~~"
274
+ suffix = "~~" + suffix
275
+ if span["char_flags"] & TEXT_BOLD:
276
+ prefix += "**"
277
+ suffix = "**" + suffix
278
+ if span["flags"] & pymupdf.TEXT_FONT_ITALIC:
279
+ prefix += "_"
280
+ suffix = "_" + suffix
281
+ if span["flags"] & pymupdf.TEXT_FONT_MONOSPACED:
282
+ prefix += "`"
283
+ suffix = "`" + suffix
284
+
285
+ if len(span["chars"]) > 2:
286
+ span_text = span_text.rstrip()
287
+
288
+ # if span continues previous styling: extend cell text
289
+ if (ls := len(suffix)) and text.endswith(suffix):
290
+ text = text[:-ls] + span_text + suffix
291
+ else: # append the span with new styling
292
+ if not span_text.strip():
293
+ text += " "
294
+ else:
295
+ text += prefix + span_text + suffix
296
+
297
+ return text.strip()
298
+
299
+
300
+ # -------------------------------------------------------------------
301
+ # End of PyMuPDF interface code
302
+ # -------------------------------------------------------------------
303
+
304
+
305
+ class UnsetFloat(float):
306
+ pass
307
+
308
+
309
+ NON_NEGATIVE_SETTINGS = [
310
+ "snap_tolerance",
311
+ "snap_x_tolerance",
312
+ "snap_y_tolerance",
313
+ "join_tolerance",
314
+ "join_x_tolerance",
315
+ "join_y_tolerance",
316
+ "edge_min_length",
317
+ "min_words_vertical",
318
+ "min_words_horizontal",
319
+ "intersection_tolerance",
320
+ "intersection_x_tolerance",
321
+ "intersection_y_tolerance",
322
+ ]
323
+
324
+
325
+ TABLE_STRATEGIES = ["lines", "lines_strict", "text", "explicit"]
326
+ UNSET = UnsetFloat(0)
327
+ DEFAULT_SNAP_TOLERANCE = 3
328
+ DEFAULT_JOIN_TOLERANCE = 3
329
+ DEFAULT_MIN_WORDS_VERTICAL = 3
330
+ DEFAULT_MIN_WORDS_HORIZONTAL = 1
331
+ DEFAULT_X_TOLERANCE = 3
332
+ DEFAULT_Y_TOLERANCE = 3
333
+ DEFAULT_X_DENSITY = 7.25
334
+ DEFAULT_Y_DENSITY = 13
335
+ bbox_getter = itemgetter("x0", "top", "x1", "bottom")
336
+
337
+
338
+ LIGATURES = {
339
+ "ff": "ff",
340
+ "ffi": "ffi",
341
+ "ffl": "ffl",
342
+ "fi": "fi",
343
+ "fl": "fl",
344
+ "st": "st",
345
+ "ſt": "st",
346
+ }
347
+
348
+
349
+ def to_list(collection) -> list:
350
+ if isinstance(collection, list):
351
+ return collection
352
+ elif isinstance(collection, Sequence):
353
+ return list(collection)
354
+ elif hasattr(collection, "to_dict"):
355
+ res = collection.to_dict("records") # pragma: nocover
356
+ return res
357
+ else:
358
+ return list(collection)
359
+
360
+
361
+ class TextMap:
362
+ """
363
+ A TextMap maps each unicode character in the text to an individual `char`
364
+ object (or, in the case of layout-implied whitespace, `None`).
365
+ """
366
+
367
+ def __init__(self, tuples=None) -> None:
368
+ self.tuples = tuples
369
+ self.as_string = "".join(map(itemgetter(0), tuples))
370
+
371
+ def match_to_dict(
372
+ self,
373
+ m,
374
+ main_group: int = 0,
375
+ return_groups: bool = True,
376
+ return_chars: bool = True,
377
+ ) -> dict:
378
+ subset = self.tuples[m.start(main_group) : m.end(main_group)]
379
+ chars = [c for (text, c) in subset if c is not None]
380
+ x0, top, x1, bottom = objects_to_bbox(chars)
381
+
382
+ result = {
383
+ "text": m.group(main_group),
384
+ "x0": x0,
385
+ "top": top,
386
+ "x1": x1,
387
+ "bottom": bottom,
388
+ }
389
+
390
+ if return_groups:
391
+ result["groups"] = m.groups()
392
+
393
+ if return_chars:
394
+ result["chars"] = chars
395
+
396
+ return result
397
+
398
+
399
+ class WordMap:
400
+ """
401
+ A WordMap maps words->chars.
402
+ """
403
+
404
+ def __init__(self, tuples) -> None:
405
+ self.tuples = tuples
406
+
407
+ def to_textmap(
408
+ self,
409
+ layout: bool = False,
410
+ layout_width=0,
411
+ layout_height=0,
412
+ layout_width_chars: int = 0,
413
+ layout_height_chars: int = 0,
414
+ x_density=DEFAULT_X_DENSITY,
415
+ y_density=DEFAULT_Y_DENSITY,
416
+ x_shift=0,
417
+ y_shift=0,
418
+ y_tolerance=DEFAULT_Y_TOLERANCE,
419
+ use_text_flow: bool = False,
420
+ presorted: bool = False,
421
+ expand_ligatures: bool = True,
422
+ ) -> TextMap:
423
+ """
424
+ Given a list of (word, chars) tuples (i.e., a WordMap), return a list of
425
+ (char-text, char) tuples (i.e., a TextMap) that can be used to mimic the
426
+ structural layout of the text on the page(s), using the following approach:
427
+
428
+ - Sort the words by (doctop, x0) if not already sorted.
429
+
430
+ - Calculate the initial doctop for the starting page.
431
+
432
+ - Cluster the words by doctop (taking `y_tolerance` into account), and
433
+ iterate through them.
434
+
435
+ - For each cluster, calculate the distance between that doctop and the
436
+ initial doctop, in points, minus `y_shift`. Divide that distance by
437
+ `y_density` to calculate the minimum number of newlines that should come
438
+ before this cluster. Append that number of newlines *minus* the number of
439
+ newlines already appended, with a minimum of one.
440
+
441
+ - Then for each cluster, iterate through each word in it. Divide each
442
+ word's x0, minus `x_shift`, by `x_density` to calculate the minimum
443
+ number of characters that should come before this cluster. Append that
444
+ number of spaces *minus* the number of characters and spaces already
445
+ appended, with a minimum of one. Then append the word's text.
446
+
447
+ - At the termination of each line, add more spaces if necessary to
448
+ mimic `layout_width`.
449
+
450
+ - Finally, add newlines to the end if necessary to mimic to
451
+ `layout_height`.
452
+
453
+ Note: This approach currently works best for horizontal, left-to-right
454
+ text, but will display all words regardless of orientation. There is room
455
+ for improvement in better supporting right-to-left text, as well as
456
+ vertical text.
457
+ """
458
+ _textmap = []
459
+
460
+ if not len(self.tuples):
461
+ return TextMap(_textmap)
462
+
463
+ expansions = LIGATURES if expand_ligatures else {}
464
+
465
+ if layout:
466
+ if layout_width_chars:
467
+ if layout_width:
468
+ raise ValueError(
469
+ "`layout_width` and `layout_width_chars` cannot both be set."
470
+ )
471
+ else:
472
+ layout_width_chars = int(round(layout_width / x_density))
473
+
474
+ if layout_height_chars:
475
+ if layout_height:
476
+ raise ValueError(
477
+ "`layout_height` and `layout_height_chars` cannot both be set."
478
+ )
479
+ else:
480
+ layout_height_chars = int(round(layout_height / y_density))
481
+
482
+ blank_line = [(" ", None)] * layout_width_chars
483
+ else:
484
+ blank_line = []
485
+
486
+ num_newlines = 0
487
+
488
+ words_sorted_doctop = (
489
+ self.tuples
490
+ if presorted or use_text_flow
491
+ else sorted(self.tuples, key=lambda x: float(x[0]["doctop"]))
492
+ )
493
+
494
+ first_word = words_sorted_doctop[0][0]
495
+ doctop_start = first_word["doctop"] - first_word["top"]
496
+
497
+ for i, ws in enumerate(
498
+ cluster_objects(
499
+ words_sorted_doctop, lambda x: float(x[0]["doctop"]), y_tolerance
500
+ )
501
+ ):
502
+ y_dist = (
503
+ (ws[0][0]["doctop"] - (doctop_start + y_shift)) / y_density
504
+ if layout
505
+ else 0
506
+ )
507
+ num_newlines_prepend = max(
508
+ # At least one newline, unless this iis the first line
509
+ int(i > 0),
510
+ # ... or as many as needed to get the imputed "distance" from the top
511
+ round(y_dist) - num_newlines,
512
+ )
513
+
514
+ for i in range(num_newlines_prepend):
515
+ if not len(_textmap) or _textmap[-1][0] == "\n":
516
+ _textmap += blank_line
517
+ _textmap.append(("\n", None))
518
+
519
+ num_newlines += num_newlines_prepend
520
+
521
+ line_len = 0
522
+
523
+ line_words_sorted_x0 = (
524
+ ws
525
+ if presorted or use_text_flow
526
+ else sorted(ws, key=lambda x: float(x[0]["x0"]))
527
+ )
528
+
529
+ for word, chars in line_words_sorted_x0:
530
+ x_dist = (word["x0"] - x_shift) / x_density if layout else 0
531
+ num_spaces_prepend = max(min(1, line_len), round(x_dist) - line_len)
532
+ _textmap += [(" ", None)] * num_spaces_prepend
533
+ line_len += num_spaces_prepend
534
+
535
+ for c in chars:
536
+ letters = expansions.get(c["text"], c["text"])
537
+ for letter in letters:
538
+ _textmap.append((letter, c))
539
+ line_len += 1
540
+
541
+ # Append spaces at end of line
542
+ if layout:
543
+ _textmap += [(" ", None)] * (layout_width_chars - line_len)
544
+
545
+ # Append blank lines at end of text
546
+ if layout:
547
+ num_newlines_append = layout_height_chars - (num_newlines + 1)
548
+ for i in range(num_newlines_append):
549
+ if i > 0:
550
+ _textmap += blank_line
551
+ _textmap.append(("\n", None))
552
+
553
+ # Remove terminal newline
554
+ if _textmap[-1] == ("\n", None):
555
+ _textmap = _textmap[:-1]
556
+
557
+ return TextMap(_textmap)
558
+
559
+
560
+ class WordExtractor:
561
+ def __init__(
562
+ self,
563
+ x_tolerance=DEFAULT_X_TOLERANCE,
564
+ y_tolerance=DEFAULT_Y_TOLERANCE,
565
+ keep_blank_chars: bool = False,
566
+ use_text_flow=False,
567
+ horizontal_ltr=True, # Should words be read left-to-right?
568
+ vertical_ttb=False, # Should vertical words be read top-to-bottom?
569
+ extra_attrs=None,
570
+ split_at_punctuation=False,
571
+ expand_ligatures=True,
572
+ ):
573
+ self.x_tolerance = x_tolerance
574
+ self.y_tolerance = y_tolerance
575
+ self.keep_blank_chars = keep_blank_chars
576
+ self.use_text_flow = use_text_flow
577
+ self.horizontal_ltr = horizontal_ltr
578
+ self.vertical_ttb = vertical_ttb
579
+ self.extra_attrs = [] if extra_attrs is None else extra_attrs
580
+
581
+ # Note: string.punctuation = '!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~'
582
+ self.split_at_punctuation = (
583
+ string.punctuation
584
+ if split_at_punctuation is True
585
+ else (split_at_punctuation or "")
586
+ )
587
+
588
+ self.expansions = LIGATURES if expand_ligatures else {}
589
+
590
+ def merge_chars(self, ordered_chars: list):
591
+ x0, top, x1, bottom = objects_to_bbox(ordered_chars)
592
+ doctop_adj = ordered_chars[0]["doctop"] - ordered_chars[0]["top"]
593
+ upright = ordered_chars[0]["upright"]
594
+ direction = 1 if (self.horizontal_ltr if upright else self.vertical_ttb) else -1
595
+
596
+ matrix = ordered_chars[0]["matrix"]
597
+
598
+ rotation = 0
599
+ if not upright and matrix[1] < 0:
600
+ ordered_chars = reversed(ordered_chars)
601
+ rotation = 270
602
+
603
+ if matrix[0] < 0 and matrix[3] < 0:
604
+ rotation = 180
605
+ elif matrix[1] > 0:
606
+ rotation = 90
607
+
608
+ word = {
609
+ "text": "".join(
610
+ self.expansions.get(c["text"], c["text"]) for c in ordered_chars
611
+ ),
612
+ "x0": x0,
613
+ "x1": x1,
614
+ "top": top,
615
+ "doctop": top + doctop_adj,
616
+ "bottom": bottom,
617
+ "upright": upright,
618
+ "direction": direction,
619
+ "rotation": rotation,
620
+ }
621
+
622
+ for key in self.extra_attrs:
623
+ word[key] = ordered_chars[0][key]
624
+
625
+ return word
626
+
627
+ def char_begins_new_word(
628
+ self,
629
+ prev_char,
630
+ curr_char,
631
+ ) -> bool:
632
+ """This method takes several factors into account to determine if
633
+ `curr_char` represents the beginning of a new word:
634
+
635
+ - Whether the text is "upright" (i.e., non-rotated)
636
+ - Whether the user has specified that horizontal text runs
637
+ left-to-right (default) or right-to-left, as represented by
638
+ self.horizontal_ltr
639
+ - Whether the user has specified that vertical text the text runs
640
+ top-to-bottom (default) or bottom-to-top, as represented by
641
+ self.vertical_ttb
642
+ - The x0, top, x1, and bottom attributes of prev_char and
643
+ curr_char
644
+ - The self.x_tolerance and self.y_tolerance settings. Note: In
645
+ this case, x/y refer to those directions for non-rotated text.
646
+ For vertical text, they are flipped. A more accurate terminology
647
+ might be "*intra*line character distance tolerance" and
648
+ "*inter*line character distance tolerance"
649
+
650
+ An important note: The *intra*line distance is measured from the
651
+ *end* of the previous character to the *beginning* of the current
652
+ character, while the *inter*line distance is measured from the
653
+ *top* of the previous character to the *top* of the next
654
+ character. The reasons for this are partly repository-historical,
655
+ and partly logical, as successive text lines' bounding boxes often
656
+ overlap slightly (and we don't want that overlap to be interpreted
657
+ as the two lines being the same line).
658
+
659
+ The upright-ness of the character determines the attributes to
660
+ compare, while horizontal_ltr/vertical_ttb determine the direction
661
+ of the comparison.
662
+ """
663
+
664
+ # Note: Due to the grouping step earlier in the process,
665
+ # curr_char["upright"] will always equal prev_char["upright"].
666
+ if curr_char["upright"]:
667
+ x = self.x_tolerance
668
+ y = self.y_tolerance
669
+ ay = prev_char["top"]
670
+ cy = curr_char["top"]
671
+ if self.horizontal_ltr:
672
+ ax = prev_char["x0"]
673
+ bx = prev_char["x1"]
674
+ cx = curr_char["x0"]
675
+ else:
676
+ ax = -prev_char["x1"]
677
+ bx = -prev_char["x0"]
678
+ cx = -curr_char["x1"]
679
+
680
+ else:
681
+ x = self.y_tolerance
682
+ y = self.x_tolerance
683
+ ay = prev_char["x0"]
684
+ cy = curr_char["x0"]
685
+ if self.vertical_ttb:
686
+ ax = prev_char["top"]
687
+ bx = prev_char["bottom"]
688
+ cx = curr_char["top"]
689
+ else:
690
+ ax = -prev_char["bottom"]
691
+ bx = -prev_char["top"]
692
+ cx = -curr_char["bottom"]
693
+
694
+ return bool(
695
+ # Intraline test
696
+ (cx < ax)
697
+ or (cx > bx + x)
698
+ # Interline test
699
+ or (cy > ay + y)
700
+ )
701
+
702
+ def iter_chars_to_words(self, ordered_chars):
703
+ current_word: list = []
704
+
705
+ def start_next_word(new_char=None):
706
+ nonlocal current_word
707
+
708
+ if current_word:
709
+ yield current_word
710
+
711
+ current_word = [] if new_char is None else [new_char]
712
+
713
+ for char in ordered_chars:
714
+ text = char["text"]
715
+
716
+ if not self.keep_blank_chars and text.isspace():
717
+ yield from start_next_word(None)
718
+
719
+ elif text in self.split_at_punctuation:
720
+ yield from start_next_word(char)
721
+ yield from start_next_word(None)
722
+
723
+ elif current_word and self.char_begins_new_word(current_word[-1], char):
724
+ yield from start_next_word(char)
725
+
726
+ else:
727
+ current_word.append(char)
728
+
729
+ # Finally, after all chars processed
730
+ if current_word:
731
+ yield current_word
732
+
733
+ def iter_sort_chars(self, chars):
734
+ def upright_key(x) -> int:
735
+ return -int(x["upright"])
736
+
737
+ for upright_cluster in cluster_objects(list(chars), upright_key, 0):
738
+ upright = upright_cluster[0]["upright"]
739
+ cluster_key = "doctop" if upright else "x0"
740
+
741
+ # Cluster by line
742
+ subclusters = cluster_objects(
743
+ upright_cluster, itemgetter(cluster_key), self.y_tolerance
744
+ )
745
+
746
+ for sc in subclusters:
747
+ # Sort within line
748
+ sort_key = "x0" if upright else "doctop"
749
+ to_yield = sorted(sc, key=itemgetter(sort_key))
750
+
751
+ # Reverse order if necessary
752
+ if not (self.horizontal_ltr if upright else self.vertical_ttb):
753
+ yield from reversed(to_yield)
754
+ else:
755
+ yield from to_yield
756
+
757
+ def iter_extract_tuples(self, chars):
758
+ ordered_chars = chars if self.use_text_flow else self.iter_sort_chars(chars)
759
+
760
+ grouping_key = itemgetter("upright", *self.extra_attrs)
761
+ grouped_chars = itertools.groupby(ordered_chars, grouping_key)
762
+
763
+ for keyvals, char_group in grouped_chars:
764
+ for word_chars in self.iter_chars_to_words(char_group):
765
+ yield (self.merge_chars(word_chars), word_chars)
766
+
767
+ def extract_wordmap(self, chars) -> WordMap:
768
+ return WordMap(list(self.iter_extract_tuples(chars)))
769
+
770
+ def extract_words(self, chars: list) -> list:
771
+ words = list(word for word, word_chars in self.iter_extract_tuples(chars))
772
+ return words
773
+
774
+
775
+ def extract_words(chars: list, **kwargs) -> list:
776
+ return WordExtractor(**kwargs).extract_words(chars)
777
+
778
+
779
+ TEXTMAP_KWARGS = inspect.signature(WordMap.to_textmap).parameters.keys()
780
+ WORD_EXTRACTOR_KWARGS = inspect.signature(WordExtractor).parameters.keys()
781
+
782
+
783
+ def chars_to_textmap(chars: list, **kwargs) -> TextMap:
784
+ kwargs.update({"presorted": True})
785
+
786
+ extractor = WordExtractor(
787
+ **{k: kwargs[k] for k in WORD_EXTRACTOR_KWARGS if k in kwargs}
788
+ )
789
+ wordmap = extractor.extract_wordmap(chars)
790
+ textmap = wordmap.to_textmap(
791
+ **{k: kwargs[k] for k in TEXTMAP_KWARGS if k in kwargs}
792
+ )
793
+
794
+ return textmap
795
+
796
+
797
+ def extract_text(chars: list, **kwargs) -> str:
798
+ chars = to_list(chars)
799
+ if len(chars) == 0:
800
+ return ""
801
+
802
+ if kwargs.get("layout"):
803
+ return chars_to_textmap(chars, **kwargs).as_string
804
+ else:
805
+ y_tolerance = kwargs.get("y_tolerance", DEFAULT_Y_TOLERANCE)
806
+ extractor = WordExtractor(
807
+ **{k: kwargs[k] for k in WORD_EXTRACTOR_KWARGS if k in kwargs}
808
+ )
809
+ words = extractor.extract_words(chars)
810
+ if words:
811
+ rotation = words[0]["rotation"] # rotation cannot change within a cell
812
+ else:
813
+ rotation = 0
814
+
815
+ if rotation == 90:
816
+ words.sort(key=lambda w: (w["x1"], -w["top"]))
817
+ lines = " ".join([w["text"] for w in words])
818
+ elif rotation == 270:
819
+ words.sort(key=lambda w: (-w["x1"], w["top"]))
820
+ lines = " ".join([w["text"] for w in words])
821
+ else:
822
+ lines = cluster_objects(words, itemgetter("doctop"), y_tolerance)
823
+ lines = "\n".join(" ".join(word["text"] for word in line) for line in lines)
824
+ if rotation == 180: # needs extra treatment
825
+ lines = "".join([(c if c != "\n" else " ") for c in reversed(lines)])
826
+
827
+ return lines
828
+
829
+
830
+ def collate_line(
831
+ line_chars: list,
832
+ tolerance=DEFAULT_X_TOLERANCE,
833
+ ) -> str:
834
+ coll = ""
835
+ last_x1 = None
836
+ for char in sorted(line_chars, key=itemgetter("x0")):
837
+ if (last_x1 is not None) and (char["x0"] > (last_x1 + tolerance)):
838
+ coll += " "
839
+ last_x1 = char["x1"]
840
+ coll += char["text"]
841
+ return coll
842
+
843
+
844
+ def dedupe_chars(chars: list, tolerance=1) -> list:
845
+ """
846
+ Removes duplicate chars — those sharing the same text, fontname, size,
847
+ and positioning (within `tolerance`) as other characters in the set.
848
+ """
849
+ key = itemgetter("fontname", "size", "upright", "text")
850
+ pos_key = itemgetter("doctop", "x0")
851
+
852
+ def yield_unique_chars(chars: list):
853
+ sorted_chars = sorted(chars, key=key)
854
+ for grp, grp_chars in itertools.groupby(sorted_chars, key=key):
855
+ for y_cluster in cluster_objects(
856
+ list(grp_chars), itemgetter("doctop"), tolerance
857
+ ):
858
+ for x_cluster in cluster_objects(
859
+ y_cluster, itemgetter("x0"), tolerance
860
+ ):
861
+ yield sorted(x_cluster, key=pos_key)[0]
862
+
863
+ deduped = yield_unique_chars(chars)
864
+ return sorted(deduped, key=chars.index)
865
+
866
+
867
+ def line_to_edge(line):
868
+ edge = dict(line)
869
+ edge["orientation"] = "h" if (line["top"] == line["bottom"]) else "v"
870
+ return edge
871
+
872
+
873
+ def rect_to_edges(rect) -> list:
874
+ top, bottom, left, right = [dict(rect) for x in range(4)]
875
+ top.update(
876
+ {
877
+ "object_type": "rect_edge",
878
+ "height": 0,
879
+ "y0": rect["y1"],
880
+ "bottom": rect["top"],
881
+ "orientation": "h",
882
+ }
883
+ )
884
+ bottom.update(
885
+ {
886
+ "object_type": "rect_edge",
887
+ "height": 0,
888
+ "y1": rect["y0"],
889
+ "top": rect["top"] + rect["height"],
890
+ "doctop": rect["doctop"] + rect["height"],
891
+ "orientation": "h",
892
+ }
893
+ )
894
+ left.update(
895
+ {
896
+ "object_type": "rect_edge",
897
+ "width": 0,
898
+ "x1": rect["x0"],
899
+ "orientation": "v",
900
+ }
901
+ )
902
+ right.update(
903
+ {
904
+ "object_type": "rect_edge",
905
+ "width": 0,
906
+ "x0": rect["x1"],
907
+ "orientation": "v",
908
+ }
909
+ )
910
+ return [top, bottom, left, right]
911
+
912
+
913
+ def curve_to_edges(curve) -> list:
914
+ point_pairs = zip(curve["pts"], curve["pts"][1:])
915
+ return [
916
+ {
917
+ "object_type": "curve_edge",
918
+ "x0": min(p0[0], p1[0]),
919
+ "x1": max(p0[0], p1[0]),
920
+ "top": min(p0[1], p1[1]),
921
+ "doctop": min(p0[1], p1[1]) + (curve["doctop"] - curve["top"]),
922
+ "bottom": max(p0[1], p1[1]),
923
+ "width": abs(p0[0] - p1[0]),
924
+ "height": abs(p0[1] - p1[1]),
925
+ "orientation": "v" if p0[0] == p1[0] else ("h" if p0[1] == p1[1] else None),
926
+ }
927
+ for p0, p1 in point_pairs
928
+ ]
929
+
930
+
931
+ def obj_to_edges(obj) -> list:
932
+ t = obj["object_type"]
933
+ if "_edge" in t:
934
+ return [obj]
935
+ elif t == "line":
936
+ return [line_to_edge(obj)]
937
+ else:
938
+ return {"rect": rect_to_edges, "curve": curve_to_edges}[t](obj)
939
+
940
+
941
+ def filter_edges(
942
+ edges,
943
+ orientation=None,
944
+ edge_type=None,
945
+ min_length=1,
946
+ ) -> list:
947
+ if orientation not in ("v", "h", None):
948
+ raise ValueError("Orientation must be 'v' or 'h'")
949
+
950
+ def test(e) -> bool:
951
+ dim = "height" if e["orientation"] == "v" else "width"
952
+ et_correct = e["object_type"] == edge_type if edge_type is not None else True
953
+ orient_correct = orientation is None or e["orientation"] == orientation
954
+ return bool(et_correct and orient_correct and (e[dim] >= min_length))
955
+
956
+ return list(filter(test, edges))
957
+
958
+
959
+ def cluster_list(xs, tolerance=0) -> list:
960
+ if tolerance == 0:
961
+ return [[x] for x in sorted(xs)]
962
+ if len(xs) < 2:
963
+ return [[x] for x in sorted(xs)]
964
+ groups = []
965
+ xs = list(sorted(xs))
966
+ current_group = [xs[0]]
967
+ last = xs[0]
968
+ for x in xs[1:]:
969
+ if x <= (last + tolerance):
970
+ current_group.append(x)
971
+ else:
972
+ groups.append(current_group)
973
+ current_group = [x]
974
+ last = x
975
+ groups.append(current_group)
976
+ return groups
977
+
978
+
979
+ def make_cluster_dict(values, tolerance) -> dict:
980
+ clusters = cluster_list(list(set(values)), tolerance)
981
+
982
+ nested_tuples = [
983
+ [(val, i) for val in value_cluster] for i, value_cluster in enumerate(clusters)
984
+ ]
985
+
986
+ return dict(itertools.chain(*nested_tuples))
987
+
988
+
989
+ def cluster_objects(xs, key_fn, tolerance) -> list:
990
+ if not callable(key_fn):
991
+ key_fn = itemgetter(key_fn)
992
+
993
+ values = map(key_fn, xs)
994
+ cluster_dict = make_cluster_dict(values, tolerance)
995
+
996
+ get_0, get_1 = itemgetter(0), itemgetter(1)
997
+
998
+ cluster_tuples = sorted(((x, cluster_dict.get(key_fn(x))) for x in xs), key=get_1)
999
+
1000
+ grouped = itertools.groupby(cluster_tuples, key=get_1)
1001
+
1002
+ return [list(map(get_0, v)) for k, v in grouped]
1003
+
1004
+
1005
+ def move_object(obj, axis: str, value):
1006
+ assert axis in ("h", "v")
1007
+ if axis == "h":
1008
+ new_items = [
1009
+ ("x0", obj["x0"] + value),
1010
+ ("x1", obj["x1"] + value),
1011
+ ]
1012
+ if axis == "v":
1013
+ new_items = [
1014
+ ("top", obj["top"] + value),
1015
+ ("bottom", obj["bottom"] + value),
1016
+ ]
1017
+ if "doctop" in obj:
1018
+ new_items += [("doctop", obj["doctop"] + value)]
1019
+ if "y0" in obj:
1020
+ new_items += [
1021
+ ("y0", obj["y0"] - value),
1022
+ ("y1", obj["y1"] - value),
1023
+ ]
1024
+ return obj.__class__(tuple(obj.items()) + tuple(new_items))
1025
+
1026
+
1027
+ def snap_objects(objs, attr: str, tolerance) -> list:
1028
+ axis = {"x0": "h", "x1": "h", "top": "v", "bottom": "v"}[attr]
1029
+ list_objs = list(objs)
1030
+ clusters = cluster_objects(list_objs, itemgetter(attr), tolerance)
1031
+ avgs = [sum(map(itemgetter(attr), cluster)) / len(cluster) for cluster in clusters]
1032
+ snapped_clusters = [
1033
+ [move_object(obj, axis, avg - obj[attr]) for obj in cluster]
1034
+ for cluster, avg in zip(clusters, avgs)
1035
+ ]
1036
+ return list(itertools.chain(*snapped_clusters))
1037
+
1038
+
1039
+ def snap_edges(
1040
+ edges,
1041
+ x_tolerance=DEFAULT_SNAP_TOLERANCE,
1042
+ y_tolerance=DEFAULT_SNAP_TOLERANCE,
1043
+ ):
1044
+ """
1045
+ Given a list of edges, snap any within `tolerance` pixels of one another
1046
+ to their positional average.
1047
+ """
1048
+ by_orientation = {"v": [], "h": []}
1049
+ for e in edges:
1050
+ by_orientation[e["orientation"]].append(e)
1051
+
1052
+ snapped_v = snap_objects(by_orientation["v"], "x0", x_tolerance)
1053
+ snapped_h = snap_objects(by_orientation["h"], "top", y_tolerance)
1054
+ return snapped_v + snapped_h
1055
+
1056
+
1057
+ def resize_object(obj, key: str, value):
1058
+ assert key in ("x0", "x1", "top", "bottom")
1059
+ old_value = obj[key]
1060
+ diff = value - old_value
1061
+ new_items = [
1062
+ (key, value),
1063
+ ]
1064
+ if key == "x0":
1065
+ assert value <= obj["x1"]
1066
+ new_items.append(("width", obj["x1"] - value))
1067
+ elif key == "x1":
1068
+ assert value >= obj["x0"]
1069
+ new_items.append(("width", value - obj["x0"]))
1070
+ elif key == "top":
1071
+ assert value <= obj["bottom"]
1072
+ new_items.append(("doctop", obj["doctop"] + diff))
1073
+ new_items.append(("height", obj["height"] - diff))
1074
+ if "y1" in obj:
1075
+ new_items.append(("y1", obj["y1"] - diff))
1076
+ elif key == "bottom":
1077
+ assert value >= obj["top"]
1078
+ new_items.append(("height", obj["height"] + diff))
1079
+ if "y0" in obj:
1080
+ new_items.append(("y0", obj["y0"] - diff))
1081
+ return obj.__class__(tuple(obj.items()) + tuple(new_items))
1082
+
1083
+
1084
+ def join_edge_group(edges, orientation: str, tolerance=DEFAULT_JOIN_TOLERANCE):
1085
+ """
1086
+ Given a list of edges along the same infinite line, join those that
1087
+ are within `tolerance` pixels of one another.
1088
+ """
1089
+ if orientation == "h":
1090
+ min_prop, max_prop = "x0", "x1"
1091
+ elif orientation == "v":
1092
+ min_prop, max_prop = "top", "bottom"
1093
+ else:
1094
+ raise ValueError("Orientation must be 'v' or 'h'")
1095
+
1096
+ sorted_edges = list(sorted(edges, key=itemgetter(min_prop)))
1097
+ joined = [sorted_edges[0]]
1098
+ for e in sorted_edges[1:]:
1099
+ last = joined[-1]
1100
+ if e[min_prop] <= (last[max_prop] + tolerance):
1101
+ if e[max_prop] > last[max_prop]:
1102
+ # Extend current edge to new extremity
1103
+ joined[-1] = resize_object(last, max_prop, e[max_prop])
1104
+ else:
1105
+ # Edge is separate from previous edges
1106
+ joined.append(e)
1107
+
1108
+ return joined
1109
+
1110
+
1111
+ def merge_edges(
1112
+ edges,
1113
+ snap_x_tolerance,
1114
+ snap_y_tolerance,
1115
+ join_x_tolerance,
1116
+ join_y_tolerance,
1117
+ ):
1118
+ """
1119
+ Using the `snap_edges` and `join_edge_group` methods above,
1120
+ merge a list of edges into a more "seamless" list.
1121
+ """
1122
+
1123
+ def get_group(edge):
1124
+ if edge["orientation"] == "h":
1125
+ return ("h", edge["top"])
1126
+ else:
1127
+ return ("v", edge["x0"])
1128
+
1129
+ if snap_x_tolerance > 0 or snap_y_tolerance > 0:
1130
+ edges = snap_edges(edges, snap_x_tolerance, snap_y_tolerance)
1131
+
1132
+ _sorted = sorted(edges, key=get_group)
1133
+ edge_groups = itertools.groupby(_sorted, key=get_group)
1134
+ edge_gen = (
1135
+ join_edge_group(
1136
+ items, k[0], (join_x_tolerance if k[0] == "h" else join_y_tolerance)
1137
+ )
1138
+ for k, items in edge_groups
1139
+ )
1140
+ edges = list(itertools.chain(*edge_gen))
1141
+ return edges
1142
+
1143
+
1144
+ def bbox_to_rect(bbox) -> dict:
1145
+ """
1146
+ Return the rectangle (i.e a dict with keys "x0", "top", "x1",
1147
+ "bottom") for an object.
1148
+ """
1149
+ return {"x0": bbox[0], "top": bbox[1], "x1": bbox[2], "bottom": bbox[3]}
1150
+
1151
+
1152
+ def objects_to_rect(objects) -> dict:
1153
+ """
1154
+ Given an iterable of objects, return the smallest rectangle (i.e. a
1155
+ dict with "x0", "top", "x1", and "bottom" keys) that contains them
1156
+ all.
1157
+ """
1158
+ return bbox_to_rect(objects_to_bbox(objects))
1159
+
1160
+
1161
+ def merge_bboxes(bboxes):
1162
+ """
1163
+ Given an iterable of bounding boxes, return the smallest bounding box
1164
+ that contains them all.
1165
+ """
1166
+ x0, top, x1, bottom = zip(*bboxes)
1167
+ return (min(x0), min(top), max(x1), max(bottom))
1168
+
1169
+
1170
+ def objects_to_bbox(objects):
1171
+ """
1172
+ Given an iterable of objects, return the smallest bounding box that
1173
+ contains them all.
1174
+ """
1175
+ return merge_bboxes(map(bbox_getter, objects))
1176
+
1177
+
1178
+ def words_to_edges_h(words, word_threshold: int = DEFAULT_MIN_WORDS_HORIZONTAL):
1179
+ """
1180
+ Find (imaginary) horizontal lines that connect the tops
1181
+ of at least `word_threshold` words.
1182
+ """
1183
+ by_top = cluster_objects(words, itemgetter("top"), 1)
1184
+ large_clusters = filter(lambda x: len(x) >= word_threshold, by_top)
1185
+ rects = list(map(objects_to_rect, large_clusters))
1186
+ if len(rects) == 0:
1187
+ return []
1188
+ min_x0 = min(map(itemgetter("x0"), rects))
1189
+ max_x1 = max(map(itemgetter("x1"), rects))
1190
+
1191
+ edges = []
1192
+ for r in rects:
1193
+ edges += [
1194
+ # Top of text
1195
+ {
1196
+ "x0": min_x0,
1197
+ "x1": max_x1,
1198
+ "top": r["top"],
1199
+ "bottom": r["top"],
1200
+ "width": max_x1 - min_x0,
1201
+ "orientation": "h",
1202
+ },
1203
+ # For each detected row, we also add the 'bottom' line. This will
1204
+ # generate extra edges, (some will be redundant with the next row
1205
+ # 'top' line), but this catches the last row of every table.
1206
+ {
1207
+ "x0": min_x0,
1208
+ "x1": max_x1,
1209
+ "top": r["bottom"],
1210
+ "bottom": r["bottom"],
1211
+ "width": max_x1 - min_x0,
1212
+ "orientation": "h",
1213
+ },
1214
+ ]
1215
+
1216
+ return edges
1217
+
1218
+
1219
+ def get_bbox_overlap(a, b):
1220
+ a_left, a_top, a_right, a_bottom = a
1221
+ b_left, b_top, b_right, b_bottom = b
1222
+ o_left = max(a_left, b_left)
1223
+ o_right = min(a_right, b_right)
1224
+ o_bottom = min(a_bottom, b_bottom)
1225
+ o_top = max(a_top, b_top)
1226
+ o_width = o_right - o_left
1227
+ o_height = o_bottom - o_top
1228
+ if o_height >= 0 and o_width >= 0 and o_height + o_width > 0:
1229
+ return (o_left, o_top, o_right, o_bottom)
1230
+ else:
1231
+ return None
1232
+
1233
+
1234
+ def words_to_edges_v(words, word_threshold: int = DEFAULT_MIN_WORDS_VERTICAL):
1235
+ """
1236
+ Find (imaginary) vertical lines that connect the left, right, or
1237
+ center of at least `word_threshold` words.
1238
+ """
1239
+ # Find words that share the same left, right, or centerpoints
1240
+ by_x0 = cluster_objects(words, itemgetter("x0"), 1)
1241
+ by_x1 = cluster_objects(words, itemgetter("x1"), 1)
1242
+
1243
+ def get_center(word):
1244
+ return float(word["x0"] + word["x1"]) / 2
1245
+
1246
+ by_center = cluster_objects(words, get_center, 1)
1247
+ clusters = by_x0 + by_x1 + by_center
1248
+
1249
+ # Find the points that align with the most words
1250
+ sorted_clusters = sorted(clusters, key=lambda x: -len(x))
1251
+ large_clusters = filter(lambda x: len(x) >= word_threshold, sorted_clusters)
1252
+
1253
+ # For each of those points, find the bboxes fitting all matching words
1254
+ bboxes = list(map(objects_to_bbox, large_clusters))
1255
+
1256
+ # Iterate through those bboxes, condensing overlapping bboxes
1257
+ condensed_bboxes = []
1258
+ for bbox in bboxes:
1259
+ overlap = any(get_bbox_overlap(bbox, c) for c in condensed_bboxes)
1260
+ if not overlap:
1261
+ condensed_bboxes.append(bbox)
1262
+
1263
+ if not condensed_bboxes:
1264
+ return []
1265
+
1266
+ condensed_rects = map(bbox_to_rect, condensed_bboxes)
1267
+ sorted_rects = list(sorted(condensed_rects, key=itemgetter("x0")))
1268
+
1269
+ max_x1 = max(map(itemgetter("x1"), sorted_rects))
1270
+ min_top = min(map(itemgetter("top"), sorted_rects))
1271
+ max_bottom = max(map(itemgetter("bottom"), sorted_rects))
1272
+
1273
+ return [
1274
+ {
1275
+ "x0": b["x0"],
1276
+ "x1": b["x0"],
1277
+ "top": min_top,
1278
+ "bottom": max_bottom,
1279
+ "height": max_bottom - min_top,
1280
+ "orientation": "v",
1281
+ }
1282
+ for b in sorted_rects
1283
+ ] + [
1284
+ {
1285
+ "x0": max_x1,
1286
+ "x1": max_x1,
1287
+ "top": min_top,
1288
+ "bottom": max_bottom,
1289
+ "height": max_bottom - min_top,
1290
+ "orientation": "v",
1291
+ }
1292
+ ]
1293
+
1294
+
1295
+ def edges_to_intersections(edges, x_tolerance=1, y_tolerance=1) -> dict:
1296
+ """
1297
+ Given a list of edges, return the points at which they intersect
1298
+ within `tolerance` pixels.
1299
+ """
1300
+ intersections = {}
1301
+ v_edges, h_edges = [
1302
+ list(filter(lambda x: x["orientation"] == o, edges)) for o in ("v", "h")
1303
+ ]
1304
+ for v in sorted(v_edges, key=itemgetter("x0", "top")):
1305
+ for h in sorted(h_edges, key=itemgetter("top", "x0")):
1306
+ if (
1307
+ (v["top"] <= (h["top"] + y_tolerance))
1308
+ and (v["bottom"] >= (h["top"] - y_tolerance))
1309
+ and (v["x0"] >= (h["x0"] - x_tolerance))
1310
+ and (v["x0"] <= (h["x1"] + x_tolerance))
1311
+ ):
1312
+ vertex = (v["x0"], h["top"])
1313
+ if vertex not in intersections:
1314
+ intersections[vertex] = {"v": [], "h": []}
1315
+ intersections[vertex]["v"].append(v)
1316
+ intersections[vertex]["h"].append(h)
1317
+ return intersections
1318
+
1319
+
1320
+ def obj_to_bbox(obj):
1321
+ """
1322
+ Return the bounding box for an object.
1323
+ """
1324
+ return bbox_getter(obj)
1325
+
1326
+
1327
+ def intersections_to_cells(intersections):
1328
+ """
1329
+ Given a list of points (`intersections`), return all rectangular "cells"
1330
+ that those points describe.
1331
+
1332
+ `intersections` should be a dictionary with (x0, top) tuples as keys,
1333
+ and a list of edge objects as values. The edge objects should correspond
1334
+ to the edges that touch the intersection.
1335
+ """
1336
+
1337
+ def edge_connects(p1, p2) -> bool:
1338
+ def edges_to_set(edges):
1339
+ return set(map(obj_to_bbox, edges))
1340
+
1341
+ if p1[0] == p2[0]:
1342
+ common = edges_to_set(intersections[p1]["v"]).intersection(
1343
+ edges_to_set(intersections[p2]["v"])
1344
+ )
1345
+ if len(common):
1346
+ return True
1347
+
1348
+ if p1[1] == p2[1]:
1349
+ common = edges_to_set(intersections[p1]["h"]).intersection(
1350
+ edges_to_set(intersections[p2]["h"])
1351
+ )
1352
+ if len(common):
1353
+ return True
1354
+ return False
1355
+
1356
+ points = list(sorted(intersections.keys()))
1357
+ n_points = len(points)
1358
+
1359
+ def find_smallest_cell(points, i: int):
1360
+ if i == n_points - 1:
1361
+ return None
1362
+ pt = points[i]
1363
+ rest = points[i + 1 :]
1364
+ # Get all the points directly below and directly right
1365
+ below = [x for x in rest if x[0] == pt[0]]
1366
+ right = [x for x in rest if x[1] == pt[1]]
1367
+ for below_pt in below:
1368
+ if not edge_connects(pt, below_pt):
1369
+ continue
1370
+
1371
+ for right_pt in right:
1372
+ if not edge_connects(pt, right_pt):
1373
+ continue
1374
+
1375
+ bottom_right = (right_pt[0], below_pt[1])
1376
+
1377
+ if (
1378
+ (bottom_right in intersections)
1379
+ and edge_connects(bottom_right, right_pt)
1380
+ and edge_connects(bottom_right, below_pt)
1381
+ ):
1382
+ return (pt[0], pt[1], bottom_right[0], bottom_right[1])
1383
+ return None
1384
+
1385
+ cell_gen = (find_smallest_cell(points, i) for i in range(len(points)))
1386
+ return list(filter(None, cell_gen))
1387
+
1388
+
1389
+ def cells_to_tables(page, cells) -> list:
1390
+ """
1391
+ Given a list of bounding boxes (`cells`), return a list of tables that
1392
+ hold those cells most simply (and contiguously).
1393
+ """
1394
+
1395
+ def bbox_to_corners(bbox) -> tuple:
1396
+ x0, top, x1, bottom = bbox
1397
+ return ((x0, top), (x0, bottom), (x1, top), (x1, bottom))
1398
+
1399
+ remaining_cells = list(cells)
1400
+
1401
+ # Iterate through the cells found above, and assign them
1402
+ # to contiguous tables
1403
+
1404
+ current_corners = set()
1405
+ current_cells = []
1406
+
1407
+ tables = []
1408
+ while len(remaining_cells):
1409
+ initial_cell_count = len(current_cells)
1410
+ for cell in list(remaining_cells):
1411
+ cell_corners = bbox_to_corners(cell)
1412
+ # If we're just starting a table ...
1413
+ if len(current_cells) == 0:
1414
+ # ... immediately assign it to the empty group
1415
+ current_corners |= set(cell_corners)
1416
+ current_cells.append(cell)
1417
+ remaining_cells.remove(cell)
1418
+ else:
1419
+ # How many corners does this table share with the current group?
1420
+ corner_count = sum(c in current_corners for c in cell_corners)
1421
+
1422
+ # If touching on at least one corner...
1423
+ if corner_count > 0:
1424
+ # ... assign it to the current group
1425
+ current_corners |= set(cell_corners)
1426
+ current_cells.append(cell)
1427
+ remaining_cells.remove(cell)
1428
+
1429
+ # If this iteration did not find any more cells to append...
1430
+ if len(current_cells) == initial_cell_count:
1431
+ # ... start a new cell group
1432
+ tables.append(list(current_cells))
1433
+ current_corners.clear()
1434
+ current_cells.clear()
1435
+
1436
+ # Once we have exhausting the list of cells ...
1437
+
1438
+ # ... and we have a cell group that has not been stored
1439
+ if len(current_cells):
1440
+ # ... store it.
1441
+ tables.append(list(current_cells))
1442
+
1443
+ # PyMuPDF modification:
1444
+ # Remove tables without text or having only 1 column
1445
+ for i in range(len(tables) - 1, -1, -1):
1446
+ r = pymupdf.EMPTY_RECT()
1447
+ x1_vals = set()
1448
+ x0_vals = set()
1449
+ for c in tables[i]:
1450
+ r |= c
1451
+ x1_vals.add(c[2])
1452
+ x0_vals.add(c[0])
1453
+ if (
1454
+ len(x1_vals) < 2
1455
+ or len(x0_vals) < 2
1456
+ or white_spaces.issuperset(
1457
+ page.get_textbox(
1458
+ r,
1459
+ textpage=TEXTPAGE,
1460
+ )
1461
+ )
1462
+ ):
1463
+ del tables[i]
1464
+
1465
+ # Sort the tables top-to-bottom-left-to-right based on the value of the
1466
+ # topmost-and-then-leftmost coordinate of a table.
1467
+ _sorted = sorted(tables, key=lambda t: min((c[1], c[0]) for c in t))
1468
+ return _sorted
1469
+
1470
+
1471
+ class CellGroup:
1472
+ def __init__(self, cells):
1473
+ self.cells = cells
1474
+ self.bbox = (
1475
+ min(map(itemgetter(0), filter(None, cells))),
1476
+ min(map(itemgetter(1), filter(None, cells))),
1477
+ max(map(itemgetter(2), filter(None, cells))),
1478
+ max(map(itemgetter(3), filter(None, cells))),
1479
+ )
1480
+
1481
+
1482
+ class TableRow(CellGroup):
1483
+ pass
1484
+
1485
+
1486
+ class TableHeader:
1487
+ """PyMuPDF extension containing the identified table header."""
1488
+
1489
+ def __init__(self, bbox, cells, names, above):
1490
+ self.bbox = bbox
1491
+ self.cells = cells
1492
+ self.names = names
1493
+ self.external = above
1494
+
1495
+
1496
+ class Table:
1497
+ def __init__(self, page, cells):
1498
+ self.page = page
1499
+ self.cells = cells
1500
+ self.header = self._get_header() # PyMuPDF extension
1501
+
1502
+ @property
1503
+ def bbox(self):
1504
+ c = self.cells
1505
+ return (
1506
+ min(map(itemgetter(0), c)),
1507
+ min(map(itemgetter(1), c)),
1508
+ max(map(itemgetter(2), c)),
1509
+ max(map(itemgetter(3), c)),
1510
+ )
1511
+
1512
+ @property
1513
+ def rows(self) -> list:
1514
+ _sorted = sorted(self.cells, key=itemgetter(1, 0))
1515
+ xs = list(sorted(set(map(itemgetter(0), self.cells))))
1516
+ rows = []
1517
+ for y, row_cells in itertools.groupby(_sorted, itemgetter(1)):
1518
+ xdict = {cell[0]: cell for cell in row_cells}
1519
+ row = TableRow([xdict.get(x) for x in xs])
1520
+ rows.append(row)
1521
+ return rows
1522
+
1523
+ @property
1524
+ def row_count(self) -> int: # PyMuPDF extension
1525
+ return len(self.rows)
1526
+
1527
+ @property
1528
+ def col_count(self) -> int: # PyMuPDF extension
1529
+ return max([len(r.cells) for r in self.rows])
1530
+
1531
+ def extract(self, **kwargs) -> list:
1532
+ chars = CHARS
1533
+ table_arr = []
1534
+
1535
+ def char_in_bbox(char, bbox) -> bool:
1536
+ v_mid = (char["top"] + char["bottom"]) / 2
1537
+ h_mid = (char["x0"] + char["x1"]) / 2
1538
+ x0, top, x1, bottom = bbox
1539
+ return bool(
1540
+ (h_mid >= x0) and (h_mid < x1) and (v_mid >= top) and (v_mid < bottom)
1541
+ )
1542
+
1543
+ for row in self.rows:
1544
+ arr = []
1545
+ row_chars = [char for char in chars if char_in_bbox(char, row.bbox)]
1546
+
1547
+ for cell in row.cells:
1548
+ if cell is None:
1549
+ cell_text = None
1550
+ else:
1551
+ cell_chars = [
1552
+ char for char in row_chars if char_in_bbox(char, cell)
1553
+ ]
1554
+
1555
+ if len(cell_chars):
1556
+ kwargs["x_shift"] = cell[0]
1557
+ kwargs["y_shift"] = cell[1]
1558
+ if "layout" in kwargs:
1559
+ kwargs["layout_width"] = cell[2] - cell[0]
1560
+ kwargs["layout_height"] = cell[3] - cell[1]
1561
+ cell_text = extract_text(cell_chars, **kwargs)
1562
+ else:
1563
+ cell_text = ""
1564
+ arr.append(cell_text)
1565
+ table_arr.append(arr)
1566
+
1567
+ return table_arr
1568
+
1569
+ def to_markdown(self, clean=False, fill_empty=True):
1570
+ """Output table content as a string in Github-markdown format.
1571
+
1572
+ If "clean" then markdown syntax is removed from cell content.
1573
+ If "fill_empty" then cell content None is replaced by the values
1574
+ above (columns) or left (rows) in an effort to approximate row and
1575
+ columns spans.
1576
+
1577
+ """
1578
+ output = "|"
1579
+ rows = self.row_count
1580
+ cols = self.col_count
1581
+
1582
+ # cell coordinates
1583
+ cell_boxes = [[c for c in r.cells] for r in self.rows]
1584
+
1585
+ # cell text strings
1586
+ cells = [[None for i in range(cols)] for j in range(rows)]
1587
+ for i, row in enumerate(cell_boxes):
1588
+ for j, cell in enumerate(row):
1589
+ if cell is not None:
1590
+ cells[i][j] = extract_cells(
1591
+ TEXTPAGE, cell_boxes[i][j], markdown=True
1592
+ )
1593
+
1594
+ if fill_empty: # fill "None" cells where possible
1595
+
1596
+ # for rows, copy content from left to right
1597
+ for j in range(rows):
1598
+ for i in range(cols - 1):
1599
+ if cells[j][i + 1] is None:
1600
+ cells[j][i + 1] = cells[j][i]
1601
+
1602
+ # for columns, copy top to bottom
1603
+ for i in range(cols):
1604
+ for j in range(rows - 1):
1605
+ if cells[j + 1][i] is None:
1606
+ cells[j + 1][i] = cells[j][i]
1607
+
1608
+ # generate header string and MD separator
1609
+ for i, name in enumerate(self.header.names):
1610
+ if not name: # generate a name if empty
1611
+ name = f"Col{i+1}"
1612
+ name = name.replace("\n", "<br>") # use HTML line breaks
1613
+ if clean: # remove sensitive syntax
1614
+ name = html.escape(name.replace("-", "&#45;"))
1615
+ output += name + "|"
1616
+
1617
+ output += "\n"
1618
+ # insert GitHub header line separator
1619
+ output += "|" + "|".join("---" for i in range(self.col_count)) + "|\n"
1620
+
1621
+ # skip first row in details if header is part of the table
1622
+ j = 0 if self.header.external else 1
1623
+
1624
+ # iterate over detail rows
1625
+ for row in cells[j:]:
1626
+ line = "|"
1627
+ for i, cell in enumerate(row):
1628
+ # replace None cells with empty string
1629
+ # use HTML line break tag
1630
+ if cell is None:
1631
+ cell = ""
1632
+ if clean: # remove sensitive syntax
1633
+ cell = html.escape(cell.replace("-", "&#45;"))
1634
+ line += cell + "|"
1635
+ line += "\n"
1636
+ output += line
1637
+ return output + "\n"
1638
+
1639
+ def to_pandas(self, **kwargs):
1640
+ """Return a pandas DataFrame version of the table."""
1641
+ try:
1642
+ import pandas as pd
1643
+ except ModuleNotFoundError:
1644
+ pymupdf.message("Package 'pandas' is not installed")
1645
+ raise
1646
+
1647
+ pd_dict = {}
1648
+ extract = self.extract()
1649
+ hdr = self.header
1650
+ names = self.header.names
1651
+ hdr_len = len(names)
1652
+ # ensure uniqueness of column names
1653
+ for i in range(hdr_len):
1654
+ name = names[i]
1655
+ if not name:
1656
+ names[i] = f"Col{i}"
1657
+ if hdr_len != len(set(names)):
1658
+ for i in range(hdr_len):
1659
+ name = names[i]
1660
+ if name != f"Col{i}":
1661
+ names[i] = f"{i}-{name}"
1662
+
1663
+ if not hdr.external: # header is part of 'extract'
1664
+ extract = extract[1:]
1665
+
1666
+ for i in range(hdr_len):
1667
+ key = names[i]
1668
+ value = []
1669
+ for j in range(len(extract)):
1670
+ value.append(extract[j][i])
1671
+ pd_dict[key] = value
1672
+
1673
+ return pd.DataFrame(pd_dict)
1674
+
1675
+ def _get_header(self, y_tolerance=3):
1676
+ """Identify the table header.
1677
+
1678
+ *** PyMuPDF extension. ***
1679
+
1680
+ Starting from the first line above the table upwards, check if it
1681
+ qualifies to be part of the table header.
1682
+
1683
+ Criteria include:
1684
+ * A one-line table never has an extra header.
1685
+ * Column borders must not intersect any word. If this happens, all
1686
+ text of this line and above of it is ignored.
1687
+ * No excess inter-line distance: If a line further up has a distance
1688
+ of more than 1.5 times of its font size, it will be ignored and
1689
+ all lines above of it.
1690
+ * Must have same text properties.
1691
+ * Starting with the top table line, a bold text property cannot change
1692
+ back to non-bold.
1693
+
1694
+ If not all criteria are met (or there is no text above the table),
1695
+ the first table row is assumed to be the header.
1696
+ """
1697
+ page = self.page
1698
+ y_delta = y_tolerance
1699
+
1700
+ def top_row_bg_color(self):
1701
+ """
1702
+ Compare top row background color with color of same-sized bbox
1703
+ above. If different, return True indicating that the original
1704
+ table top row is already the header.
1705
+ """
1706
+ bbox0 = pymupdf.Rect(self.rows[0].bbox)
1707
+ bboxt = bbox0 + (0, -bbox0.height, 0, -bbox0.height) # area above
1708
+ top_color0 = page.get_pixmap(clip=bbox0).color_topusage()[1]
1709
+ top_colort = page.get_pixmap(clip=bboxt).color_topusage()[1]
1710
+ if top_color0 != top_colort:
1711
+ return True # top row is header
1712
+ return False
1713
+
1714
+ def row_has_bold(bbox):
1715
+ """Check if a row contains some bold text.
1716
+
1717
+ If e.g. true for the top row, then it will be used as (internal)
1718
+ column header row if any of the following is true:
1719
+ * the previous (above) text line has no bold span
1720
+ * the second table row text has no bold span
1721
+
1722
+ Returns True if any spans are bold else False.
1723
+ """
1724
+ blocks = page.get_text("dict", flags=pymupdf.TEXTFLAGS_TEXT, clip=bbox)[
1725
+ "blocks"
1726
+ ]
1727
+ spans = [s for b in blocks for l in b["lines"] for s in l["spans"]]
1728
+
1729
+ return any(s["flags"] & pymupdf.TEXT_FONT_BOLD for s in spans)
1730
+
1731
+ try:
1732
+ row = self.rows[0]
1733
+ cells = row.cells
1734
+ bbox = pymupdf.Rect(row.bbox)
1735
+ except IndexError: # this table has no rows
1736
+ return None
1737
+
1738
+ # return this if we determine that the top row is the header
1739
+ header_top_row = TableHeader(bbox, cells, self.extract()[0], False)
1740
+
1741
+ # 1-line tables have no extra header
1742
+ if len(self.rows) < 2:
1743
+ return header_top_row
1744
+
1745
+ # 1-column tables have no extra header
1746
+ if len(cells) < 2:
1747
+ return header_top_row
1748
+
1749
+ # assume top row is the header if second row is empty
1750
+ row2 = self.rows[1] # second row
1751
+ if all(c is None for c in row2.cells): # no valid cell bboxes in row2
1752
+ return header_top_row
1753
+
1754
+ # Special check: is top row bold?
1755
+ top_row_bold = row_has_bold(bbox)
1756
+
1757
+ # assume top row is header if it is bold and any cell
1758
+ # of 2nd row is non-bold
1759
+ if top_row_bold and not row_has_bold(row2.bbox):
1760
+ return header_top_row
1761
+
1762
+ if top_row_bg_color(self):
1763
+ # if area above top row has a different background color,
1764
+ # then top row is already the header
1765
+ return header_top_row
1766
+
1767
+ # column coordinates (x1 values) in top row
1768
+ col_x = [c[2] if c is not None else None for c in cells[:-1]]
1769
+
1770
+ # clip = page area above the table
1771
+ # We will inspect this area for text qualifying as column header.
1772
+ clip = +bbox # take row 0 bbox
1773
+ clip.y0 = 0 # start at top of page
1774
+ clip.y1 = bbox.y0 # end at top of table
1775
+
1776
+ blocks = page.get_text("dict", clip=clip, flags=pymupdf.TEXTFLAGS_TEXT)[
1777
+ "blocks"
1778
+ ]
1779
+ # non-empty, non-superscript spans above table, sorted descending by y1
1780
+ spans = sorted(
1781
+ [
1782
+ s
1783
+ for b in blocks
1784
+ for l in b["lines"]
1785
+ for s in l["spans"]
1786
+ if not (
1787
+ white_spaces.issuperset(s["text"])
1788
+ or s["flags"] & pymupdf.TEXT_FONT_SUPERSCRIPT
1789
+ )
1790
+ ],
1791
+ key=lambda s: s["bbox"][3],
1792
+ reverse=True,
1793
+ )
1794
+
1795
+ select = [] # y1 coordinates above, sorted descending
1796
+ line_heights = [] # line heights above, sorted descending
1797
+ line_bolds = [] # bold indicator per line above, same sorting
1798
+
1799
+ # walk through the spans and fill above 3 lists
1800
+ for i in range(len(spans)):
1801
+ s = spans[i]
1802
+ y1 = s["bbox"][3] # span bottom
1803
+ h = y1 - s["bbox"][1] # span bbox height
1804
+ bold = s["flags"] & pymupdf.TEXT_FONT_BOLD
1805
+
1806
+ # use first item to start the lists
1807
+ if i == 0:
1808
+ select.append(y1)
1809
+ line_heights.append(h)
1810
+ line_bolds.append(bold)
1811
+ continue
1812
+
1813
+ # get previous items from the 3 lists
1814
+ y0 = select[-1]
1815
+ h0 = line_heights[-1]
1816
+ bold0 = line_bolds[-1]
1817
+
1818
+ if bold0 and not bold:
1819
+ break # stop if switching from bold to non-bold
1820
+
1821
+ # if fitting in height of previous span, modify bbox
1822
+ if y0 - y1 <= y_delta or abs((y0 - h0) - s["bbox"][1]) <= y_delta:
1823
+ s["bbox"] = (s["bbox"][0], y0 - h0, s["bbox"][2], y0)
1824
+ spans[i] = s
1825
+ if bold:
1826
+ line_bolds[-1] = bold
1827
+ continue
1828
+ elif y0 - y1 > 1.5 * h0:
1829
+ break # stop if distance to previous line too large
1830
+ select.append(y1)
1831
+ line_heights.append(h)
1832
+ line_bolds.append(bold)
1833
+
1834
+ if select == []: # nothing above the table?
1835
+ return header_top_row
1836
+
1837
+ select = select[:5] # accept up to 5 lines for an external header
1838
+
1839
+ # assume top row as header if text above is too far away
1840
+ if bbox.y0 - select[0] >= line_heights[0]:
1841
+ return header_top_row
1842
+
1843
+ # accept top row as header if bold, but line above is not
1844
+ if top_row_bold and not line_bolds[0]:
1845
+ return header_top_row
1846
+
1847
+ if spans == []: # nothing left above the table, return top row
1848
+ return header_top_row
1849
+
1850
+ # re-compute clip above table
1851
+ nclip = pymupdf.EMPTY_RECT()
1852
+ for s in [s for s in spans if s["bbox"][3] >= select[-1]]:
1853
+ nclip |= s["bbox"]
1854
+ if not nclip.is_empty:
1855
+ clip = nclip
1856
+
1857
+ clip.y1 = bbox.y0 # make sure we still include every word above
1858
+
1859
+ # Confirm that no word in clip is intersecting a column separator
1860
+ word_rects = [pymupdf.Rect(w[:4]) for w in page.get_text("words", clip=clip)]
1861
+ word_tops = sorted(list(set([r[1] for r in word_rects])), reverse=True)
1862
+
1863
+ select = []
1864
+
1865
+ # exclude lines with words that intersect a column border
1866
+ for top in word_tops:
1867
+ intersecting = [
1868
+ (x, r)
1869
+ for x in col_x
1870
+ if x is not None
1871
+ for r in word_rects
1872
+ if r[1] == top and r[0] < x and r[2] > x
1873
+ ]
1874
+ if intersecting == []:
1875
+ select.append(top)
1876
+ else: # detected a word crossing a column border
1877
+ break
1878
+
1879
+ if select == []: # nothing left over: return first row
1880
+ return header_top_row
1881
+
1882
+ hdr_bbox = +clip # compute the header cells
1883
+ hdr_bbox.y0 = select[-1] # hdr_bbox top is smallest top coord of words
1884
+ hdr_cells = [
1885
+ (c[0], hdr_bbox.y0, c[2], hdr_bbox.y1) if c is not None else None
1886
+ for c in cells
1887
+ ]
1888
+
1889
+ # adjust left/right of header bbox
1890
+ hdr_bbox.x0 = self.bbox[0]
1891
+ hdr_bbox.x1 = self.bbox[2]
1892
+
1893
+ # column names: no line breaks, no excess spaces
1894
+ hdr_names = [
1895
+ (
1896
+ page.get_textbox(c).replace("\n", " ").replace(" ", " ").strip()
1897
+ if c is not None
1898
+ else ""
1899
+ )
1900
+ for c in hdr_cells
1901
+ ]
1902
+ return TableHeader(tuple(hdr_bbox), hdr_cells, hdr_names, True)
1903
+
1904
+
1905
+ @dataclass
1906
+ class TableSettings:
1907
+ vertical_strategy: str = "lines"
1908
+ horizontal_strategy: str = "lines"
1909
+ explicit_vertical_lines: list = None
1910
+ explicit_horizontal_lines: list = None
1911
+ snap_tolerance: float = DEFAULT_SNAP_TOLERANCE
1912
+ snap_x_tolerance: float = UNSET
1913
+ snap_y_tolerance: float = UNSET
1914
+ join_tolerance: float = DEFAULT_JOIN_TOLERANCE
1915
+ join_x_tolerance: float = UNSET
1916
+ join_y_tolerance: float = UNSET
1917
+ edge_min_length: float = 3
1918
+ min_words_vertical: float = DEFAULT_MIN_WORDS_VERTICAL
1919
+ min_words_horizontal: float = DEFAULT_MIN_WORDS_HORIZONTAL
1920
+ intersection_tolerance: float = 3
1921
+ intersection_x_tolerance: float = UNSET
1922
+ intersection_y_tolerance: float = UNSET
1923
+ text_settings: dict = None
1924
+
1925
+ def __post_init__(self) -> "TableSettings":
1926
+ """Clean up user-provided table settings.
1927
+
1928
+ Validates that the table settings provided consists of acceptable values and
1929
+ returns a cleaned up version. The cleaned up version fills out the missing
1930
+ values with the default values in the provided settings.
1931
+
1932
+ TODO: Can be further used to validate that the values are of the correct
1933
+ type. For example, raising a value error when a non-boolean input is
1934
+ provided for the key ``keep_blank_chars``.
1935
+
1936
+ :param table_settings: User-provided table settings.
1937
+ :returns: A cleaned up version of the user-provided table settings.
1938
+ :raises ValueError: When an unrecognised key is provided.
1939
+ """
1940
+
1941
+ for setting in NON_NEGATIVE_SETTINGS:
1942
+ if (getattr(self, setting) or 0) < 0:
1943
+ raise ValueError(f"Table setting '{setting}' cannot be negative")
1944
+
1945
+ for orientation in ["horizontal", "vertical"]:
1946
+ strategy = getattr(self, orientation + "_strategy")
1947
+ if strategy not in TABLE_STRATEGIES:
1948
+ raise ValueError(
1949
+ f"{orientation}_strategy must be one of"
1950
+ f'{{{",".join(TABLE_STRATEGIES)}}}'
1951
+ )
1952
+
1953
+ if self.text_settings is None:
1954
+ self.text_settings = {}
1955
+
1956
+ # This next section is for backwards compatibility
1957
+ for attr in ["x_tolerance", "y_tolerance"]:
1958
+ if attr not in self.text_settings:
1959
+ self.text_settings[attr] = self.text_settings.get("tolerance", 3)
1960
+
1961
+ if "tolerance" in self.text_settings:
1962
+ del self.text_settings["tolerance"]
1963
+ # End of that section
1964
+
1965
+ for attr, fallback in [
1966
+ ("snap_x_tolerance", "snap_tolerance"),
1967
+ ("snap_y_tolerance", "snap_tolerance"),
1968
+ ("join_x_tolerance", "join_tolerance"),
1969
+ ("join_y_tolerance", "join_tolerance"),
1970
+ ("intersection_x_tolerance", "intersection_tolerance"),
1971
+ ("intersection_y_tolerance", "intersection_tolerance"),
1972
+ ]:
1973
+ if getattr(self, attr) is UNSET:
1974
+ setattr(self, attr, getattr(self, fallback))
1975
+
1976
+ return self
1977
+
1978
+ @classmethod
1979
+ def resolve(cls, settings=None):
1980
+ if settings is None:
1981
+ return cls()
1982
+ elif isinstance(settings, cls):
1983
+ return settings
1984
+ elif isinstance(settings, dict):
1985
+ core_settings = {}
1986
+ text_settings = {}
1987
+ for k, v in settings.items():
1988
+ if k[:5] == "text_":
1989
+ text_settings[k[5:]] = v
1990
+ else:
1991
+ core_settings[k] = v
1992
+ core_settings["text_settings"] = text_settings
1993
+ return cls(**core_settings)
1994
+ else:
1995
+ raise ValueError(f"Cannot resolve settings: {settings}")
1996
+
1997
+
1998
+ class TableFinder:
1999
+ """
2000
+ Given a PDF page, find plausible table structures.
2001
+
2002
+ Largely borrowed from Anssi Nurminen's master's thesis:
2003
+ http://dspace.cc.tut.fi/dpub/bitstream/handle/123456789/21520/Nurminen.pdf?sequence=3
2004
+
2005
+ ... and inspired by Tabula:
2006
+ https://github.com/tabulapdf/tabula-extractor/issues/16
2007
+ """
2008
+
2009
+ def __init__(self, page, settings=None):
2010
+ self.page = weakref.proxy(page)
2011
+ self.settings = TableSettings.resolve(settings)
2012
+ self.edges = self.get_edges()
2013
+ self.intersections = edges_to_intersections(
2014
+ self.edges,
2015
+ self.settings.intersection_x_tolerance,
2016
+ self.settings.intersection_y_tolerance,
2017
+ )
2018
+ self.cells = intersections_to_cells(self.intersections)
2019
+ self.tables = [
2020
+ Table(self.page, cell_group)
2021
+ for cell_group in cells_to_tables(self.page, self.cells)
2022
+ ]
2023
+
2024
+ def get_edges(self) -> list:
2025
+ settings = self.settings
2026
+
2027
+ for orientation in ["vertical", "horizontal"]:
2028
+ strategy = getattr(settings, orientation + "_strategy")
2029
+ if strategy == "explicit":
2030
+ lines = getattr(settings, "explicit_" + orientation + "_lines")
2031
+ if len(lines) < 2:
2032
+ raise ValueError(
2033
+ f"If {orientation}_strategy == 'explicit', "
2034
+ f"explicit_{orientation}_lines "
2035
+ f"must be specified as a list/tuple of two or more "
2036
+ f"floats/ints."
2037
+ )
2038
+
2039
+ v_strat = settings.vertical_strategy
2040
+ h_strat = settings.horizontal_strategy
2041
+
2042
+ if v_strat == "text" or h_strat == "text":
2043
+ words = extract_words(CHARS, **(settings.text_settings or {}))
2044
+ else:
2045
+ words = []
2046
+
2047
+ v_explicit = []
2048
+ for desc in settings.explicit_vertical_lines or []:
2049
+ if isinstance(desc, dict):
2050
+ for e in obj_to_edges(desc):
2051
+ if e["orientation"] == "v":
2052
+ v_explicit.append(e)
2053
+ else:
2054
+ v_explicit.append(
2055
+ {
2056
+ "x0": desc,
2057
+ "x1": desc,
2058
+ "top": self.page.rect[1],
2059
+ "bottom": self.page.rect[3],
2060
+ "height": self.page.rect[3] - self.page.rect[1],
2061
+ "orientation": "v",
2062
+ }
2063
+ )
2064
+
2065
+ if v_strat == "lines":
2066
+ v_base = filter_edges(EDGES, "v")
2067
+ elif v_strat == "lines_strict":
2068
+ v_base = filter_edges(EDGES, "v", edge_type="line")
2069
+ elif v_strat == "text":
2070
+ v_base = words_to_edges_v(words, word_threshold=settings.min_words_vertical)
2071
+ elif v_strat == "explicit":
2072
+ v_base = []
2073
+ else:
2074
+ v_base = []
2075
+
2076
+ v = v_base + v_explicit
2077
+
2078
+ h_explicit = []
2079
+ for desc in settings.explicit_horizontal_lines or []:
2080
+ if isinstance(desc, dict):
2081
+ for e in obj_to_edges(desc):
2082
+ if e["orientation"] == "h":
2083
+ h_explicit.append(e)
2084
+ else:
2085
+ h_explicit.append(
2086
+ {
2087
+ "x0": self.page.rect[0],
2088
+ "x1": self.page.rect[2],
2089
+ "width": self.page.rect[2] - self.page.rect[0],
2090
+ "top": desc,
2091
+ "bottom": desc,
2092
+ "orientation": "h",
2093
+ }
2094
+ )
2095
+
2096
+ if h_strat == "lines":
2097
+ h_base = filter_edges(EDGES, "h")
2098
+ elif h_strat == "lines_strict":
2099
+ h_base = filter_edges(EDGES, "h", edge_type="line")
2100
+ elif h_strat == "text":
2101
+ h_base = words_to_edges_h(
2102
+ words, word_threshold=settings.min_words_horizontal
2103
+ )
2104
+ elif h_strat == "explicit":
2105
+ h_base = []
2106
+ else:
2107
+ h_base = []
2108
+
2109
+ h = h_base + h_explicit
2110
+
2111
+ edges = list(v) + list(h)
2112
+
2113
+ edges = merge_edges(
2114
+ edges,
2115
+ snap_x_tolerance=settings.snap_x_tolerance,
2116
+ snap_y_tolerance=settings.snap_y_tolerance,
2117
+ join_x_tolerance=settings.join_x_tolerance,
2118
+ join_y_tolerance=settings.join_y_tolerance,
2119
+ )
2120
+
2121
+ return filter_edges(edges, min_length=settings.edge_min_length)
2122
+
2123
+ def __getitem__(self, i):
2124
+ tcount = len(self.tables)
2125
+ if i >= tcount:
2126
+ raise IndexError("table not on page")
2127
+ while i < 0:
2128
+ i += tcount
2129
+ return self.tables[i]
2130
+
2131
+
2132
+ """
2133
+ Start of PyMuPDF interface code.
2134
+ The following functions are executed when "page.find_tables()" is called.
2135
+
2136
+ * make_chars: Fills the CHARS list with text character information extracted
2137
+ via "rawdict" text extraction. Items in CHARS are formatted
2138
+ as expected by the table code.
2139
+ * make_edges: Fills the EDGES list with vector graphic information extracted
2140
+ via "get_drawings". Items in EDGES are formatted as expected
2141
+ by the table code.
2142
+
2143
+ The lists CHARS and EDGES are used to replace respective document access
2144
+ of pdfplumber or, respectively pdfminer.
2145
+ The table code has been modified to use these lists instead of accessing
2146
+ page information themselves.
2147
+ """
2148
+
2149
+
2150
+ # -----------------------------------------------------------------------------
2151
+ # Extract all page characters to fill the CHARS list
2152
+ # -----------------------------------------------------------------------------
2153
+ def make_chars(page, clip=None):
2154
+ """Extract text as "rawdict" to fill CHARS."""
2155
+ global TEXTPAGE
2156
+ page_number = page.number + 1
2157
+ page_height = page.rect.height
2158
+ ctm = page.transformation_matrix
2159
+ TEXTPAGE = page.get_textpage(clip=clip, flags=FLAGS)
2160
+ blocks = page.get_text("rawdict", textpage=TEXTPAGE)["blocks"]
2161
+ doctop_base = page_height * page.number
2162
+ for block in blocks:
2163
+ for line in block["lines"]:
2164
+ ldir = line["dir"] # = (cosine, sine) of angle
2165
+ ldir = (round(ldir[0], 4), round(ldir[1], 4))
2166
+ matrix = pymupdf.Matrix(ldir[0], -ldir[1], ldir[1], ldir[0], 0, 0)
2167
+ if ldir[1] == 0:
2168
+ upright = True
2169
+ else:
2170
+ upright = False
2171
+ for span in sorted(line["spans"], key=lambda s: s["bbox"][0]):
2172
+ fontname = span["font"]
2173
+ fontsize = span["size"]
2174
+ color = pymupdf.sRGB_to_pdf(span["color"])
2175
+ for char in sorted(span["chars"], key=lambda c: c["bbox"][0]):
2176
+ bbox = pymupdf.Rect(char["bbox"])
2177
+ bbox_ctm = bbox * ctm
2178
+ origin = pymupdf.Point(char["origin"]) * ctm
2179
+ matrix.e = origin.x
2180
+ matrix.f = origin.y
2181
+ text = char["c"]
2182
+ char_dict = {
2183
+ "adv": bbox.x1 - bbox.x0 if upright else bbox.y1 - bbox.y0,
2184
+ "bottom": bbox.y1,
2185
+ "doctop": bbox.y0 + doctop_base,
2186
+ "fontname": fontname,
2187
+ "height": bbox.y1 - bbox.y0,
2188
+ "matrix": tuple(matrix),
2189
+ "ncs": "DeviceRGB",
2190
+ "non_stroking_color": color,
2191
+ "non_stroking_pattern": None,
2192
+ "object_type": "char",
2193
+ "page_number": page_number,
2194
+ "size": fontsize if upright else bbox.y1 - bbox.y0,
2195
+ "stroking_color": color,
2196
+ "stroking_pattern": None,
2197
+ "text": text,
2198
+ "top": bbox.y0,
2199
+ "upright": upright,
2200
+ "width": bbox.x1 - bbox.x0,
2201
+ "x0": bbox.x0,
2202
+ "x1": bbox.x1,
2203
+ "y0": bbox_ctm.y0,
2204
+ "y1": bbox_ctm.y1,
2205
+ }
2206
+ CHARS.append(char_dict)
2207
+
2208
+
2209
+ # ------------------------------------------------------------------------
2210
+ # Extract all page vector graphics to fill the EDGES list.
2211
+ # We are ignoring Bézier curves completely and are converting everything
2212
+ # else to lines.
2213
+ # ------------------------------------------------------------------------
2214
+ def make_edges(page, clip=None, tset=None, paths=None, add_lines=None, add_boxes=None):
2215
+ snap_x = tset.snap_x_tolerance
2216
+ snap_y = tset.snap_y_tolerance
2217
+ min_length = tset.edge_min_length
2218
+ lines_strict = (
2219
+ tset.vertical_strategy == "lines_strict"
2220
+ or tset.horizontal_strategy == "lines_strict"
2221
+ )
2222
+ page_height = page.rect.height
2223
+ doctop_basis = page.number * page_height
2224
+ page_number = page.number + 1
2225
+ prect = page.rect
2226
+ if page.rotation in (90, 270):
2227
+ w, h = prect.br
2228
+ prect = pymupdf.Rect(0, 0, h, w)
2229
+ if clip is not None:
2230
+ clip = pymupdf.Rect(clip)
2231
+ else:
2232
+ clip = prect
2233
+
2234
+ def are_neighbors(r1, r2):
2235
+ """Detect whether r1, r2 are neighbors.
2236
+
2237
+ Defined as:
2238
+ The minimum distance between points of r1 and points of r2 is not
2239
+ larger than some delta.
2240
+
2241
+ This check supports empty rect-likes and thus also lines.
2242
+
2243
+ Note:
2244
+ This type of check is MUCH faster than native Rect containment checks.
2245
+ """
2246
+ if ( # check if x-coordinates of r1 are within those of r2
2247
+ r2.x0 - snap_x <= r1.x0 <= r2.x1 + snap_x
2248
+ or r2.x0 - snap_x <= r1.x1 <= r2.x1 + snap_x
2249
+ ) and ( # ... same for y-coordinates
2250
+ r2.y0 - snap_y <= r1.y0 <= r2.y1 + snap_y
2251
+ or r2.y0 - snap_y <= r1.y1 <= r2.y1 + snap_y
2252
+ ):
2253
+ return True
2254
+
2255
+ # same check with r1 / r2 exchanging their roles (this is necessary!)
2256
+ if (
2257
+ r1.x0 - snap_x <= r2.x0 <= r1.x1 + snap_x
2258
+ or r1.x0 - snap_x <= r2.x1 <= r1.x1 + snap_x
2259
+ ) and (
2260
+ r1.y0 - snap_y <= r2.y0 <= r1.y1 + snap_y
2261
+ or r1.y0 - snap_y <= r2.y1 <= r1.y1 + snap_y
2262
+ ):
2263
+ return True
2264
+ return False
2265
+
2266
+ def clean_graphics(npaths=None):
2267
+ """Detect and join rectangles of "connected" vector graphics."""
2268
+ if npaths is None:
2269
+ allpaths = page.get_drawings()
2270
+ else: # accept passed-in vector graphics
2271
+ allpaths = npaths[:] # paths relevant for table detection
2272
+ paths = []
2273
+ for p in allpaths:
2274
+ # If only looking at lines, we ignore fill-only paths,
2275
+ # except simulated lines (i.e. small width or height).
2276
+ if (
2277
+ lines_strict
2278
+ and p["type"] == "f"
2279
+ and p["rect"].width > snap_x
2280
+ and p["rect"].height > snap_y
2281
+ ):
2282
+ continue
2283
+ paths.append(p)
2284
+
2285
+ # start with all vector graphics rectangles
2286
+ prects = sorted(set([p["rect"] for p in paths]), key=lambda r: (r.y1, r.x0))
2287
+ new_rects = [] # the final list of joined rectangles
2288
+ # ----------------------------------------------------------------
2289
+ # Strategy: Join rectangles that "almost touch" each other.
2290
+ # Extend first rectangle with any other that is a "neighbor".
2291
+ # Then move it to the final list and continue with the rest.
2292
+ # ----------------------------------------------------------------
2293
+ while prects: # the algorithm will empty this list
2294
+ prect0 = prects[0] # copy of first rectangle (performance reasons!)
2295
+ repeat = True
2296
+ while repeat: # this loop extends first rect in list
2297
+ repeat = False # set to true again if some other rect touches
2298
+ for i in range(len(prects) - 1, 0, -1): # run backwards
2299
+ if are_neighbors(prect0, prects[i]): # close enough to rect 0?
2300
+ prect0 |= prects[i].tl # extend rect 0
2301
+ prect0 |= prects[i].br # extend rect 0
2302
+ del prects[i] # delete this rect
2303
+ repeat = True # keep checking the rest
2304
+
2305
+ # move rect 0 over to result list if there is some text in it
2306
+ if not white_spaces.issuperset(page.get_textbox(prect0, textpage=TEXTPAGE)):
2307
+ # contains text, so accept it as a table bbox candidate
2308
+ new_rects.append(prect0)
2309
+ del prects[0] # remove from rect list
2310
+
2311
+ return new_rects, paths
2312
+
2313
+ bboxes, paths = clean_graphics(npaths=paths)
2314
+
2315
+ def is_parallel(p1, p2):
2316
+ """Check if line is roughly axis-parallel."""
2317
+ if abs(p1.x - p2.x) <= snap_x or abs(p1.y - p2.y) <= snap_y:
2318
+ return True
2319
+ return False
2320
+
2321
+ def make_line(p, p1, p2, clip):
2322
+ """Given 2 points, make a line dictionary for table detection."""
2323
+ if not is_parallel(p1, p2): # only accepting axis-parallel lines
2324
+ return {}
2325
+ # compute the extremal values
2326
+ x0 = min(p1.x, p2.x)
2327
+ x1 = max(p1.x, p2.x)
2328
+ y0 = min(p1.y, p2.y)
2329
+ y1 = max(p1.y, p2.y)
2330
+
2331
+ # check for outside clip
2332
+ if x0 > clip.x1 or x1 < clip.x0 or y0 > clip.y1 or y1 < clip.y0:
2333
+ return {}
2334
+
2335
+ if x0 < clip.x0:
2336
+ x0 = clip.x0 # adjust to clip boundary
2337
+
2338
+ if x1 > clip.x1:
2339
+ x1 = clip.x1 # adjust to clip boundary
2340
+
2341
+ if y0 < clip.y0:
2342
+ y0 = clip.y0 # adjust to clip boundary
2343
+
2344
+ if y1 > clip.y1:
2345
+ y1 = clip.y1 # adjust to clip boundary
2346
+
2347
+ width = x1 - x0 # from adjusted values
2348
+ height = y1 - y0 # from adjusted values
2349
+ if width == height == 0:
2350
+ return {} # nothing left to deal with
2351
+ line_dict = {
2352
+ "x0": x0,
2353
+ "y0": page_height - y0,
2354
+ "x1": x1,
2355
+ "y1": page_height - y1,
2356
+ "width": width,
2357
+ "height": height,
2358
+ "pts": [(x0, y0), (x1, y1)],
2359
+ "linewidth": p["width"],
2360
+ "stroke": True,
2361
+ "fill": False,
2362
+ "evenodd": False,
2363
+ "stroking_color": p["color"] if p["color"] else p["fill"],
2364
+ "non_stroking_color": None,
2365
+ "object_type": "line",
2366
+ "page_number": page_number,
2367
+ "stroking_pattern": None,
2368
+ "non_stroking_pattern": None,
2369
+ "top": y0,
2370
+ "bottom": y1,
2371
+ "doctop": y0 + doctop_basis,
2372
+ }
2373
+ return line_dict
2374
+
2375
+ for p in paths:
2376
+ items = p["items"] # items in this path
2377
+
2378
+ # if 'closePath', add a line from last to first point
2379
+ if p["closePath"] and items[0][0] == "l" and items[-1][0] == "l":
2380
+ items.append(("l", items[-1][2], items[0][1]))
2381
+
2382
+ for i in items:
2383
+ if i[0] not in ("l", "re", "qu"):
2384
+ continue # ignore anything else
2385
+
2386
+ if i[0] == "l": # a line
2387
+ p1, p2 = i[1:]
2388
+ line_dict = make_line(p, p1, p2, clip)
2389
+ if line_dict:
2390
+ EDGES.append(line_to_edge(line_dict))
2391
+
2392
+ elif i[0] == "re":
2393
+ # A rectangle: decompose into 4 lines, but filter out
2394
+ # the ones that simulate a line
2395
+ rect = i[1].normalize() # normalize the rectangle
2396
+
2397
+ if (
2398
+ rect.width <= min_length and rect.width < rect.height
2399
+ ): # simulates a vertical line
2400
+ x = abs(rect.x1 + rect.x0) / 2 # take middle value for x
2401
+ p1 = pymupdf.Point(x, rect.y0)
2402
+ p2 = pymupdf.Point(x, rect.y1)
2403
+ line_dict = make_line(p, p1, p2, clip)
2404
+ if line_dict:
2405
+ EDGES.append(line_to_edge(line_dict))
2406
+ continue
2407
+
2408
+ if (
2409
+ rect.height <= min_length and rect.height < rect.width
2410
+ ): # simulates a horizontal line
2411
+ y = abs(rect.y1 + rect.y0) / 2 # take middle value for y
2412
+ p1 = pymupdf.Point(rect.x0, y)
2413
+ p2 = pymupdf.Point(rect.x1, y)
2414
+ line_dict = make_line(p, p1, p2, clip)
2415
+ if line_dict:
2416
+ EDGES.append(line_to_edge(line_dict))
2417
+ continue
2418
+
2419
+ line_dict = make_line(p, rect.tl, rect.bl, clip)
2420
+ if line_dict:
2421
+ EDGES.append(line_to_edge(line_dict))
2422
+
2423
+ line_dict = make_line(p, rect.bl, rect.br, clip)
2424
+ if line_dict:
2425
+ EDGES.append(line_to_edge(line_dict))
2426
+
2427
+ line_dict = make_line(p, rect.br, rect.tr, clip)
2428
+ if line_dict:
2429
+ EDGES.append(line_to_edge(line_dict))
2430
+
2431
+ line_dict = make_line(p, rect.tr, rect.tl, clip)
2432
+ if line_dict:
2433
+ EDGES.append(line_to_edge(line_dict))
2434
+
2435
+ else: # must be a quad
2436
+ # we convert it into (up to) 4 lines
2437
+ ul, ur, ll, lr = i[1]
2438
+
2439
+ line_dict = make_line(p, ul, ll, clip)
2440
+ if line_dict:
2441
+ EDGES.append(line_to_edge(line_dict))
2442
+
2443
+ line_dict = make_line(p, ll, lr, clip)
2444
+ if line_dict:
2445
+ EDGES.append(line_to_edge(line_dict))
2446
+
2447
+ line_dict = make_line(p, lr, ur, clip)
2448
+ if line_dict:
2449
+ EDGES.append(line_to_edge(line_dict))
2450
+
2451
+ line_dict = make_line(p, ur, ul, clip)
2452
+ if line_dict:
2453
+ EDGES.append(line_to_edge(line_dict))
2454
+
2455
+ path = {"color": (0, 0, 0), "fill": None, "width": 1}
2456
+ for bbox in bboxes: # add the border lines for all enveloping bboxes
2457
+ line_dict = make_line(path, bbox.tl, bbox.tr, clip)
2458
+ if line_dict:
2459
+ EDGES.append(line_to_edge(line_dict))
2460
+
2461
+ line_dict = make_line(path, bbox.bl, bbox.br, clip)
2462
+ if line_dict:
2463
+ EDGES.append(line_to_edge(line_dict))
2464
+
2465
+ line_dict = make_line(path, bbox.tl, bbox.bl, clip)
2466
+ if line_dict:
2467
+ EDGES.append(line_to_edge(line_dict))
2468
+
2469
+ line_dict = make_line(path, bbox.tr, bbox.br, clip)
2470
+ if line_dict:
2471
+ EDGES.append(line_to_edge(line_dict))
2472
+
2473
+ if add_lines is not None: # add user-specified lines
2474
+ assert isinstance(add_lines, (tuple, list))
2475
+ else:
2476
+ add_lines = []
2477
+ for p1, p2 in add_lines:
2478
+ p1 = pymupdf.Point(p1)
2479
+ p2 = pymupdf.Point(p2)
2480
+ line_dict = make_line(path, p1, p2, clip)
2481
+ if line_dict:
2482
+ EDGES.append(line_to_edge(line_dict))
2483
+
2484
+ if add_boxes is not None: # add user-specified rectangles
2485
+ assert isinstance(add_boxes, (tuple, list))
2486
+ else:
2487
+ add_boxes = []
2488
+ for box in add_boxes:
2489
+ r = pymupdf.Rect(box)
2490
+ line_dict = make_line(path, r.tl, r.bl, clip)
2491
+ if line_dict:
2492
+ EDGES.append(line_to_edge(line_dict))
2493
+ line_dict = make_line(path, r.bl, r.br, clip)
2494
+ if line_dict:
2495
+ EDGES.append(line_to_edge(line_dict))
2496
+ line_dict = make_line(path, r.br, r.tr, clip)
2497
+ if line_dict:
2498
+ EDGES.append(line_to_edge(line_dict))
2499
+ line_dict = make_line(path, r.tr, r.tl, clip)
2500
+ if line_dict:
2501
+ EDGES.append(line_to_edge(line_dict))
2502
+
2503
+
2504
+ def page_rotation_set0(page):
2505
+ """Nullify page rotation.
2506
+
2507
+ To correctly detect tables, page rotation must be zero.
2508
+ This function performs the necessary adjustments and returns information
2509
+ for reverting this changes.
2510
+ """
2511
+ mediabox = page.mediabox
2512
+ rot = page.rotation # contains normalized rotation value
2513
+ # need to derotate the page's content
2514
+ mb = page.mediabox # current mediabox
2515
+
2516
+ if rot == 90:
2517
+ # before derotation, shift content horizontally
2518
+ mat0 = pymupdf.Matrix(1, 0, 0, 1, mb.y1 - mb.x1 - mb.x0 - mb.y0, 0)
2519
+ elif rot == 270:
2520
+ # before derotation, shift content vertically
2521
+ mat0 = pymupdf.Matrix(1, 0, 0, 1, 0, mb.x1 - mb.y1 - mb.y0 - mb.x0)
2522
+ else:
2523
+ mat0 = pymupdf.Matrix(1, 0, 0, 1, -2 * mb.x0, -2 * mb.y0)
2524
+
2525
+ # prefix with derotation matrix
2526
+ mat = mat0 * page.derotation_matrix
2527
+ cmd = b"%g %g %g %g %g %g cm " % tuple(mat)
2528
+ xref = pymupdf.TOOLS._insert_contents(page, cmd, 0)
2529
+
2530
+ # swap x- and y-coordinates
2531
+ if rot in (90, 270):
2532
+ x0, y0, x1, y1 = mb
2533
+ mb.x0 = y0
2534
+ mb.y0 = x0
2535
+ mb.x1 = y1
2536
+ mb.y1 = x1
2537
+ page.set_mediabox(mb)
2538
+
2539
+ page.set_rotation(0)
2540
+
2541
+ # refresh the page to apply these changes
2542
+ doc = page.parent
2543
+ pno = page.number
2544
+ page = doc[pno]
2545
+ return page, xref, rot, mediabox
2546
+
2547
+
2548
+ def page_rotation_reset(page, xref, rot, mediabox):
2549
+ """Reset page rotation to original values.
2550
+
2551
+ To be used before we return tables."""
2552
+ doc = page.parent # document of the page
2553
+ doc.update_stream(xref, b" ") # remove de-rotation matrix
2554
+ page.set_mediabox(mediabox) # set mediabox to old value
2555
+ page.set_rotation(rot) # set rotation to old value
2556
+ pno = page.number
2557
+ page = doc[pno] # update page info
2558
+ return page
2559
+
2560
+
2561
+ def find_tables(
2562
+ page,
2563
+ clip=None,
2564
+ vertical_strategy: str = "lines",
2565
+ horizontal_strategy: str = "lines",
2566
+ vertical_lines: list = None,
2567
+ horizontal_lines: list = None,
2568
+ snap_tolerance: float = DEFAULT_SNAP_TOLERANCE,
2569
+ snap_x_tolerance: float = None,
2570
+ snap_y_tolerance: float = None,
2571
+ join_tolerance: float = DEFAULT_JOIN_TOLERANCE,
2572
+ join_x_tolerance: float = None,
2573
+ join_y_tolerance: float = None,
2574
+ edge_min_length: float = 3,
2575
+ min_words_vertical: float = DEFAULT_MIN_WORDS_VERTICAL,
2576
+ min_words_horizontal: float = DEFAULT_MIN_WORDS_HORIZONTAL,
2577
+ intersection_tolerance: float = 3,
2578
+ intersection_x_tolerance: float = None,
2579
+ intersection_y_tolerance: float = None,
2580
+ text_tolerance=3,
2581
+ text_x_tolerance=3,
2582
+ text_y_tolerance=3,
2583
+ strategy=None, # offer abbreviation
2584
+ add_lines=None, # user-specified lines
2585
+ add_boxes=None, # user-specified rectangles
2586
+ paths=None, # accept vector graphics as parameter
2587
+ ):
2588
+ pymupdf._warn_layout_once()
2589
+ global CHARS, EDGES
2590
+ CHARS = []
2591
+ EDGES = []
2592
+ old_small = bool(pymupdf.TOOLS.set_small_glyph_heights()) # save old value
2593
+ pymupdf.TOOLS.set_small_glyph_heights(True) # we need minimum bboxes
2594
+ if page.rotation != 0:
2595
+ page, old_xref, old_rot, old_mediabox = page_rotation_set0(page)
2596
+ else:
2597
+ old_xref, old_rot, old_mediabox = None, None, None
2598
+
2599
+ if snap_x_tolerance is None:
2600
+ snap_x_tolerance = UNSET
2601
+ if snap_y_tolerance is None:
2602
+ snap_y_tolerance = UNSET
2603
+ if join_x_tolerance is None:
2604
+ join_x_tolerance = UNSET
2605
+ if join_y_tolerance is None:
2606
+ join_y_tolerance = UNSET
2607
+ if intersection_x_tolerance is None:
2608
+ intersection_x_tolerance = UNSET
2609
+ if intersection_y_tolerance is None:
2610
+ intersection_y_tolerance = UNSET
2611
+ if strategy is not None:
2612
+ vertical_strategy = strategy
2613
+ horizontal_strategy = strategy
2614
+
2615
+ settings = {
2616
+ "vertical_strategy": vertical_strategy,
2617
+ "horizontal_strategy": horizontal_strategy,
2618
+ "explicit_vertical_lines": vertical_lines,
2619
+ "explicit_horizontal_lines": horizontal_lines,
2620
+ "snap_tolerance": snap_tolerance,
2621
+ "snap_x_tolerance": snap_x_tolerance,
2622
+ "snap_y_tolerance": snap_y_tolerance,
2623
+ "join_tolerance": join_tolerance,
2624
+ "join_x_tolerance": join_x_tolerance,
2625
+ "join_y_tolerance": join_y_tolerance,
2626
+ "edge_min_length": edge_min_length,
2627
+ "min_words_vertical": min_words_vertical,
2628
+ "min_words_horizontal": min_words_horizontal,
2629
+ "intersection_tolerance": intersection_tolerance,
2630
+ "intersection_x_tolerance": intersection_x_tolerance,
2631
+ "intersection_y_tolerance": intersection_y_tolerance,
2632
+ "text_tolerance": text_tolerance,
2633
+ "text_x_tolerance": text_x_tolerance,
2634
+ "text_y_tolerance": text_y_tolerance,
2635
+ }
2636
+
2637
+ old_quad_corrections = pymupdf.TOOLS.unset_quad_corrections()
2638
+ try:
2639
+ page.get_layout()
2640
+ if page.layout_information:
2641
+ pymupdf.TOOLS.unset_quad_corrections(True)
2642
+ boxes = [
2643
+ pymupdf.Rect(b[:4]) for b in page.layout_information if b[-1] == "table"
2644
+ ]
2645
+ else:
2646
+ boxes = []
2647
+
2648
+ if boxes: # layout did find some tables
2649
+ pass
2650
+ elif page.layout_information is not None:
2651
+ # layout was executed but found no tables
2652
+ # make sure we exit quickly with an empty TableFinder
2653
+ tbf = TableFinder(page)
2654
+ return tbf
2655
+
2656
+ tset = TableSettings.resolve(settings=settings)
2657
+ page.table_settings = tset
2658
+
2659
+ make_chars(page, clip=clip) # create character list of page
2660
+ make_edges(
2661
+ page,
2662
+ clip=clip,
2663
+ tset=tset,
2664
+ paths=paths,
2665
+ add_lines=add_lines,
2666
+ add_boxes=add_boxes,
2667
+ ) # create lines and curves
2668
+
2669
+ tbf = TableFinder(page, settings=tset)
2670
+
2671
+ if boxes:
2672
+ # only keep Finder tables that match a layout box
2673
+ tbf.tables = [
2674
+ tab
2675
+ for tab in tbf.tables
2676
+ if any(_iou(tab.bbox, r) >= 0.6 for r in boxes)
2677
+ ]
2678
+ # build the complementary list of layout table boxes
2679
+ my_boxes = [
2680
+ r for r in boxes if all(_iou(r, tab.bbox) < 0.6 for tab in tbf.tables)
2681
+ ]
2682
+ if my_boxes:
2683
+ word_rects = [pymupdf.Rect(w[:4]) for w in TEXTPAGE.extractWORDS()]
2684
+ tp2 = page.get_textpage(flags=TABLE_DETECTOR_FLAGS)
2685
+ for rect in my_boxes:
2686
+ cells = make_table_from_bbox(tp2, word_rects, rect) # pylint: disable=E0606
2687
+ tbf.tables.append(Table(page, cells))
2688
+ except Exception as e:
2689
+ pymupdf.message("find_tables: exception occurred: %s" % str(e))
2690
+ return None
2691
+ finally:
2692
+ pymupdf.TOOLS.set_small_glyph_heights(old_small)
2693
+ if old_xref is not None:
2694
+ page = page_rotation_reset(page, old_xref, old_rot, old_mediabox)
2695
+ pymupdf.TOOLS.unset_quad_corrections(old_quad_corrections)
2696
+
2697
+ return tbf