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.
- fitz/__init__.py +13 -0
- fitz/table.py +2 -0
- fitz/utils.py +2 -0
- pymupdf/__init__.py +25899 -0
- pymupdf/__main__.py +1140 -0
- pymupdf/_apply_pages.py +253 -0
- pymupdf/_build.py +8 -0
- pymupdf/_extra.pyd +0 -0
- pymupdf/_mupdf.pyd +0 -0
- pymupdf/_wxcolors.py +562 -0
- pymupdf/extra.py +220 -0
- pymupdf/mupdf-devel/include/mupdf/classes.h +19999 -0
- pymupdf/mupdf-devel/include/mupdf/classes2.h +12244 -0
- pymupdf/mupdf-devel/include/mupdf/exceptions.h +118 -0
- pymupdf/mupdf-devel/include/mupdf/extra.h +189 -0
- pymupdf/mupdf-devel/include/mupdf/fitz/archive.h +444 -0
- pymupdf/mupdf-devel/include/mupdf/fitz/band-writer.h +117 -0
- pymupdf/mupdf-devel/include/mupdf/fitz/barcode.h +138 -0
- pymupdf/mupdf-devel/include/mupdf/fitz/bidi.h +90 -0
- pymupdf/mupdf-devel/include/mupdf/fitz/bitmap.h +188 -0
- pymupdf/mupdf-devel/include/mupdf/fitz/buffer.h +250 -0
- pymupdf/mupdf-devel/include/mupdf/fitz/color.h +433 -0
- pymupdf/mupdf-devel/include/mupdf/fitz/compress.h +132 -0
- pymupdf/mupdf-devel/include/mupdf/fitz/compressed-buffer.h +194 -0
- pymupdf/mupdf-devel/include/mupdf/fitz/config.h +302 -0
- pymupdf/mupdf-devel/include/mupdf/fitz/context.h +1064 -0
- pymupdf/mupdf-devel/include/mupdf/fitz/crypt.h +270 -0
- pymupdf/mupdf-devel/include/mupdf/fitz/deskew.h +46 -0
- pymupdf/mupdf-devel/include/mupdf/fitz/device.h +652 -0
- pymupdf/mupdf-devel/include/mupdf/fitz/display-list.h +142 -0
- pymupdf/mupdf-devel/include/mupdf/fitz/document.h +1103 -0
- pymupdf/mupdf-devel/include/mupdf/fitz/export.h +52 -0
- pymupdf/mupdf-devel/include/mupdf/fitz/filter.h +269 -0
- pymupdf/mupdf-devel/include/mupdf/fitz/font.h +858 -0
- pymupdf/mupdf-devel/include/mupdf/fitz/geometry.h +885 -0
- pymupdf/mupdf-devel/include/mupdf/fitz/getopt.h +134 -0
- pymupdf/mupdf-devel/include/mupdf/fitz/glyph-cache.h +96 -0
- pymupdf/mupdf-devel/include/mupdf/fitz/glyph.h +81 -0
- pymupdf/mupdf-devel/include/mupdf/fitz/hash.h +126 -0
- pymupdf/mupdf-devel/include/mupdf/fitz/heap-imp.h +190 -0
- pymupdf/mupdf-devel/include/mupdf/fitz/heap.h +143 -0
- pymupdf/mupdf-devel/include/mupdf/fitz/image.h +451 -0
- pymupdf/mupdf-devel/include/mupdf/fitz/json.h +98 -0
- pymupdf/mupdf-devel/include/mupdf/fitz/link.h +130 -0
- pymupdf/mupdf-devel/include/mupdf/fitz/log.h +61 -0
- pymupdf/mupdf-devel/include/mupdf/fitz/outline.h +254 -0
- pymupdf/mupdf-devel/include/mupdf/fitz/output-svg.h +64 -0
- pymupdf/mupdf-devel/include/mupdf/fitz/output.h +448 -0
- pymupdf/mupdf-devel/include/mupdf/fitz/path.h +477 -0
- pymupdf/mupdf-devel/include/mupdf/fitz/pixmap.h +516 -0
- pymupdf/mupdf-devel/include/mupdf/fitz/pool.h +102 -0
- pymupdf/mupdf-devel/include/mupdf/fitz/separation.h +138 -0
- pymupdf/mupdf-devel/include/mupdf/fitz/shade.h +233 -0
- pymupdf/mupdf-devel/include/mupdf/fitz/store.h +465 -0
- pymupdf/mupdf-devel/include/mupdf/fitz/story-writer.h +209 -0
- pymupdf/mupdf-devel/include/mupdf/fitz/story.h +232 -0
- pymupdf/mupdf-devel/include/mupdf/fitz/stream.h +646 -0
- pymupdf/mupdf-devel/include/mupdf/fitz/string-util.h +335 -0
- pymupdf/mupdf-devel/include/mupdf/fitz/structured-text.h +924 -0
- pymupdf/mupdf-devel/include/mupdf/fitz/system.h +495 -0
- pymupdf/mupdf-devel/include/mupdf/fitz/text.h +210 -0
- pymupdf/mupdf-devel/include/mupdf/fitz/track-usage.h +57 -0
- pymupdf/mupdf-devel/include/mupdf/fitz/transition.h +76 -0
- pymupdf/mupdf-devel/include/mupdf/fitz/tree.h +62 -0
- pymupdf/mupdf-devel/include/mupdf/fitz/types.h +41 -0
- pymupdf/mupdf-devel/include/mupdf/fitz/util.h +159 -0
- pymupdf/mupdf-devel/include/mupdf/fitz/version.h +31 -0
- pymupdf/mupdf-devel/include/mupdf/fitz/write-pixmap.h +507 -0
- pymupdf/mupdf-devel/include/mupdf/fitz/writer.h +269 -0
- pymupdf/mupdf-devel/include/mupdf/fitz/xml.h +435 -0
- pymupdf/mupdf-devel/include/mupdf/fitz.h +101 -0
- pymupdf/mupdf-devel/include/mupdf/functions.h +13819 -0
- pymupdf/mupdf-devel/include/mupdf/helpers/mu-office-lib.h +666 -0
- pymupdf/mupdf-devel/include/mupdf/helpers/mu-threads.h +280 -0
- pymupdf/mupdf-devel/include/mupdf/helpers/pkcs7-openssl.h +48 -0
- pymupdf/mupdf-devel/include/mupdf/html.h +53 -0
- pymupdf/mupdf-devel/include/mupdf/internal.h +29 -0
- pymupdf/mupdf-devel/include/mupdf/memento.h +425 -0
- pymupdf/mupdf-devel/include/mupdf/pdf/annot.h +1015 -0
- pymupdf/mupdf-devel/include/mupdf/pdf/clean.h +68 -0
- pymupdf/mupdf-devel/include/mupdf/pdf/cmap.h +145 -0
- pymupdf/mupdf-devel/include/mupdf/pdf/crypt.h +108 -0
- pymupdf/mupdf-devel/include/mupdf/pdf/document.h +902 -0
- pymupdf/mupdf-devel/include/mupdf/pdf/event.h +167 -0
- pymupdf/mupdf-devel/include/mupdf/pdf/font.h +168 -0
- pymupdf/mupdf-devel/include/mupdf/pdf/form.h +381 -0
- pymupdf/mupdf-devel/include/mupdf/pdf/image-rewriter.h +78 -0
- pymupdf/mupdf-devel/include/mupdf/pdf/interpret.h +518 -0
- pymupdf/mupdf-devel/include/mupdf/pdf/javascript.h +43 -0
- pymupdf/mupdf-devel/include/mupdf/pdf/name-table.h +621 -0
- pymupdf/mupdf-devel/include/mupdf/pdf/object.h +445 -0
- pymupdf/mupdf-devel/include/mupdf/pdf/page.h +327 -0
- pymupdf/mupdf-devel/include/mupdf/pdf/parse.h +61 -0
- pymupdf/mupdf-devel/include/mupdf/pdf/recolor.h +49 -0
- pymupdf/mupdf-devel/include/mupdf/pdf/resource.h +144 -0
- pymupdf/mupdf-devel/include/mupdf/pdf/xref.h +300 -0
- pymupdf/mupdf-devel/include/mupdf/pdf/zugferd.h +49 -0
- pymupdf/mupdf-devel/include/mupdf/pdf.h +59 -0
- pymupdf/mupdf-devel/include/mupdf/ucdn.h +464 -0
- pymupdf/mupdf-devel/lib/libmuthreads.lib +0 -0
- pymupdf/mupdf-devel/lib/mupdfcpp64.lib +0 -0
- pymupdf/mupdf.py +63667 -0
- pymupdf/mupdfcpp64.dll +0 -0
- pymupdf/pymupdf.py +2 -0
- pymupdf/table.py +2697 -0
- pymupdf/utils.py +1169 -0
- pymupdf-1.26.6.dist-info/COPYING +1 -0
- pymupdf-1.26.6.dist-info/METADATA +83 -0
- pymupdf-1.26.6.dist-info/README.md +60 -0
- pymupdf-1.26.6.dist-info/RECORD +112 -0
- pymupdf-1.26.6.dist-info/WHEEL +4 -0
- 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("-", "-"))
|
|
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("-", "-"))
|
|
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
|