brewdoc 0.2.0__py3-none-any.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.
- brewdoc/__init__.py +16 -0
- brewdoc/cli.py +97 -0
- brewdoc/common.py +405 -0
- brewdoc/docx.py +131 -0
- brewdoc/htmltext.py +812 -0
- brewdoc/output.py +93 -0
- brewdoc/pdf.py +1185 -0
- brewdoc/pptx.py +362 -0
- brewdoc/selfcheck.py +383 -0
- brewdoc/service.py +116 -0
- brewdoc/sheets.py +341 -0
- brewdoc-0.2.0.dist-info/METADATA +232 -0
- brewdoc-0.2.0.dist-info/RECORD +16 -0
- brewdoc-0.2.0.dist-info/WHEEL +4 -0
- brewdoc-0.2.0.dist-info/entry_points.txt +2 -0
- brewdoc-0.2.0.dist-info/licenses/LICENSE +21 -0
brewdoc/pdf.py
ADDED
|
@@ -0,0 +1,1185 @@
|
|
|
1
|
+
"""PDF adapter: text-layer pages, each region routed by its own ruling, via pdfplumber."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import statistics
|
|
7
|
+
import unicodedata
|
|
8
|
+
from collections import Counter
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import pdfplumber
|
|
12
|
+
from pdfminer.pdftypes import resolve1
|
|
13
|
+
from pdfplumber.utils import extract_text
|
|
14
|
+
|
|
15
|
+
from brewdoc.common import (CID_RE, BrewdocError, Rendered, Route, _assemble, head_key, new_tally,
|
|
16
|
+
reading, sanitise)
|
|
17
|
+
|
|
18
|
+
# Layout thresholds, measured on real documents.
|
|
19
|
+
HIST_BINS = 60 # char x-histogram resolution for the gutter search
|
|
20
|
+
GUTTER_RATIO = 0.25 # a gutter bin holds at most this share of the median column bin
|
|
21
|
+
GUTTER_MIN_BINS = 2 # a real gutter is several bins wide; one bin is noise
|
|
22
|
+
GUTTER_BAND = (0.35, 0.65) # a gutter sits near the middle; a margin is not a gutter
|
|
23
|
+
GUTTER_MIN_CHARS = 40 # below this a region has no histogram worth reading
|
|
24
|
+
WIDE_TABLE = 0.60 # a lined table this wide means the page is a table, not two columns
|
|
25
|
+
BLANK_BAND = 1.6 # a vertical gap this many line-heights wide separates two regions
|
|
26
|
+
WIDE_GAP = 2.5 # an inter-word gap this many space-widths wide is a column boundary
|
|
27
|
+
MIN_WIDE_GAPS = 2 # a tabular line has at least this many of them
|
|
28
|
+
MIN_TABULAR_LINES = 2 # one aligned line is a heading, not a table
|
|
29
|
+
COLUMN_TOLERANCE = 3.0 # two column starts this close are the same column
|
|
30
|
+
MIN_SHARED_COLUMNS = 2 # two-column prose shares one start (the gutter); a table shares more
|
|
31
|
+
MIN_REGION = 4.0 # a band thinner than this holds no line
|
|
32
|
+
DEFAULT_SPACE = 2.5 # the space width assumed when a region has no gap to measure
|
|
33
|
+
MARGIN_BAND = 0.08 # the share of page height a running head or folio sits in
|
|
34
|
+
RULE_SNAP = 3.0 # two ruling edges this close are one line, never two columns
|
|
35
|
+
MIN_RULE_COLUMNS = 4 # a rule cut into fewer pieces is an underline, not a column grid
|
|
36
|
+
GRID_ROW_COLUMNS = 3 # a row filling this many columns is using the grid
|
|
37
|
+
GRID_ROW_SHARE = 3 # and one row in this many must, or the band is prose beside a rule
|
|
38
|
+
SUBSCRIPT = 0.75 # a char smaller than this share of its cell's body size sits off-baseline
|
|
39
|
+
TABLE_CAPTION_RE = re.compile(r"^\s*table\s+\d+", re.I)
|
|
40
|
+
PAGE_NUMBER_RE = re.compile(r"^(?:page\s+)?\d+(?:\s*(?:of|/)\s*\d+)?$", re.I)
|
|
41
|
+
REPEATS_AS_HEAD = 3 # a first/last line this many pages share is furniture, not content
|
|
42
|
+
|
|
43
|
+
TEXT_TABLE = {"vertical_strategy": "text", "horizontal_strategy": "text"}
|
|
44
|
+
|
|
45
|
+
PDF_GLYPHS = {"−": "-", "α": "[alpha]", "β": "[beta]", "θ": "[theta]", "π": "[pi]",
|
|
46
|
+
"ϕ": "[phi-symbol]", "σ": "[sigma]", "τ": "[tau]", "∇": "[nabla]",
|
|
47
|
+
"∈": "[in]", "≻": "[succeeds]", "∑": "[sum]", "∏": "[product]",
|
|
48
|
+
"∼": "~", "∗": "*", "ˆ": "^", "′": "[prime]", "·": "[middle-dot]",
|
|
49
|
+
"×": "[times]", "†": "[dagger]", "‡": "[double-dagger]", "̸": "[overlay:0338]"}
|
|
50
|
+
PDF_FONT_GLYPHS = {"summationtext": "[sum]", "summationdisplay": "[sum]",
|
|
51
|
+
"productdisplay": "[product]"}
|
|
52
|
+
for _glyph_stem, _glyph_token in (("parenleft", "("), ("parenright", ")"),
|
|
53
|
+
("bracketleft", "["), ("bracketright", "]"),
|
|
54
|
+
("braceleft", "{"), ("braceright", "}")):
|
|
55
|
+
for _glyph_size in ("big", "Big", "bigg", "Bigg"):
|
|
56
|
+
PDF_FONT_GLYPHS[_glyph_stem + _glyph_size] = _glyph_token
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# --- PDF: regions -----------------------------------------------------------------------------
|
|
60
|
+
def _line_chars(line) -> list:
|
|
61
|
+
return sorted((c for c in line["chars"] if c["text"].strip()), key=lambda c: c["x0"])
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _gaps(chars) -> list[float]:
|
|
65
|
+
return [b["x0"] - a["x1"] for a, b in zip(chars, chars[1:]) if b["x0"] - a["x1"] > 0]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def space_unit(lines) -> float:
|
|
69
|
+
"""The width of one word space in this region: the median inter-char gap, at any font size."""
|
|
70
|
+
gaps = [gap for line in lines for gap in _gaps(_line_chars(line))]
|
|
71
|
+
return statistics.median(gaps) if gaps else DEFAULT_SPACE
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def is_tabular(line, unit: float) -> bool:
|
|
75
|
+
"""A line with several column-wide gaps. Not the two-column test: see `aligned_columns`."""
|
|
76
|
+
chars = _line_chars(line)
|
|
77
|
+
if len(chars) < 3:
|
|
78
|
+
return False
|
|
79
|
+
threshold = max(4.0, WIDE_GAP * unit)
|
|
80
|
+
return sum(1 for gap in _gaps(chars) if gap >= threshold) >= MIN_WIDE_GAPS
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def detect_gutter(chars, width: float) -> float | None:
|
|
84
|
+
"""The x of a two-column gutter, or None. Relative density: a running head or a figure
|
|
85
|
+
caption crosses the gutter, so a zero-run test misses real ones."""
|
|
86
|
+
if len(chars) < GUTTER_MIN_CHARS:
|
|
87
|
+
return None
|
|
88
|
+
x0 = min(c["x0"] for c in chars)
|
|
89
|
+
span = max(c["x1"] for c in chars) - x0
|
|
90
|
+
if span < 0.5 * width:
|
|
91
|
+
return None
|
|
92
|
+
histogram = [0] * HIST_BINS
|
|
93
|
+
for char in chars:
|
|
94
|
+
histogram[min(int((char["x0"] - x0) / span * HIST_BINS), HIST_BINS - 1)] += 1
|
|
95
|
+
filled = [count for count in histogram if count]
|
|
96
|
+
if not filled:
|
|
97
|
+
return None
|
|
98
|
+
threshold = statistics.median(filled) * GUTTER_RATIO
|
|
99
|
+
index = 0
|
|
100
|
+
while index < HIST_BINS:
|
|
101
|
+
if histogram[index] > threshold:
|
|
102
|
+
index += 1
|
|
103
|
+
continue
|
|
104
|
+
end = index
|
|
105
|
+
while end < HIST_BINS and histogram[end] <= threshold:
|
|
106
|
+
end += 1
|
|
107
|
+
centre = (index + end) / 2 / HIST_BINS
|
|
108
|
+
if end - index >= GUTTER_MIN_BINS and GUTTER_BAND[0] <= centre <= GUTTER_BAND[1]:
|
|
109
|
+
return x0 + span * centre
|
|
110
|
+
index = end
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def aligned_columns(band, unit: float) -> int:
|
|
115
|
+
"""How many column starts the band's lines share: a table shares as many as it has columns,
|
|
116
|
+
two-column prose shares exactly one (the gutter)."""
|
|
117
|
+
starts, threshold = [], max(4.0, WIDE_GAP * unit)
|
|
118
|
+
for line in band:
|
|
119
|
+
chars = _line_chars(line)
|
|
120
|
+
starts.append([b["x0"] for a, b in zip(chars, chars[1:]) if b["x0"] - a["x1"] >= threshold])
|
|
121
|
+
flat = sorted(x for line in starts for x in line)
|
|
122
|
+
shared, cluster = 0, []
|
|
123
|
+
for x in flat + [None]:
|
|
124
|
+
if cluster and (x is None or x - cluster[0] > COLUMN_TOLERANCE):
|
|
125
|
+
if len(cluster) * 2 >= len(band) and len(cluster) >= MIN_TABULAR_LINES:
|
|
126
|
+
shared += 1
|
|
127
|
+
cluster = []
|
|
128
|
+
if x is not None:
|
|
129
|
+
cluster.append(x)
|
|
130
|
+
return shared
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _prose_gutters(band, width: float) -> list[float]:
|
|
134
|
+
"""Find repeated prose-column starts without treating numeric columns as page flow."""
|
|
135
|
+
chars = [char for line in band for char in _line_chars(line)]
|
|
136
|
+
if len(chars) < GUTTER_MIN_CHARS or sum(char.get("_source_text", char["text"]).isdigit() for char in chars) > len(chars) * 0.12:
|
|
137
|
+
return []
|
|
138
|
+
gaps = []
|
|
139
|
+
for index, line in enumerate(band):
|
|
140
|
+
for left, right in zip(_line_chars(line), _line_chars(line)[1:]):
|
|
141
|
+
if right["x0"] - left["x1"] >= max(8, width * 0.015):
|
|
142
|
+
gaps.append((right["x0"], left["x1"], index))
|
|
143
|
+
clusters = []
|
|
144
|
+
for gap in sorted(gaps):
|
|
145
|
+
if not clusters or gap[0] - clusters[-1][0][0] > COLUMN_TOLERANCE:
|
|
146
|
+
clusters.append([])
|
|
147
|
+
clusters[-1].append(gap)
|
|
148
|
+
gutters = []
|
|
149
|
+
for group in clusters:
|
|
150
|
+
rows = {gap[2] for gap in group}
|
|
151
|
+
if len(rows) < max(3, len(band) * 0.35):
|
|
152
|
+
continue
|
|
153
|
+
gutter = (min(gap[0] for gap in group) + max(gap[1] for gap in group)) / 2
|
|
154
|
+
# A left segment that is a bare integer on every line is a line-number rail, not a column.
|
|
155
|
+
if not all("".join(char["text"] for char in _line_chars(band[row]) if char["x1"] <= gutter).isdigit()
|
|
156
|
+
for row in rows):
|
|
157
|
+
gutters.append(gutter)
|
|
158
|
+
return gutters
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _crop(page, x0, top, x1, bottom):
|
|
162
|
+
# pdfplumber refuses a box even a hair outside the page.
|
|
163
|
+
px0, ptop, px1, pbottom = page.bbox
|
|
164
|
+
box = (max(x0, px0), max(top, ptop), min(x1, px1), min(bottom, pbottom))
|
|
165
|
+
if box[2] - box[0] < MIN_REGION or box[3] - box[1] < MIN_REGION:
|
|
166
|
+
return None
|
|
167
|
+
return page.crop(box)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _bands(lines) -> list[list]:
|
|
171
|
+
if not lines:
|
|
172
|
+
return []
|
|
173
|
+
pitch = statistics.median([line["bottom"] - line["top"] for line in lines]) or 1.0
|
|
174
|
+
bands = [[lines[0]]]
|
|
175
|
+
for previous, current in zip(lines, lines[1:]):
|
|
176
|
+
if current["top"] - previous["bottom"] > BLANK_BAND * pitch:
|
|
177
|
+
bands.append([current])
|
|
178
|
+
else:
|
|
179
|
+
bands[-1].append(current)
|
|
180
|
+
return bands
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _tabular_run(flags: list[bool]) -> tuple[int, int]:
|
|
184
|
+
best, start, index = (0, 0), 0, 0
|
|
185
|
+
while index < len(flags):
|
|
186
|
+
if not flags[index]:
|
|
187
|
+
index += 1
|
|
188
|
+
continue
|
|
189
|
+
start = index
|
|
190
|
+
while index < len(flags) and flags[index]:
|
|
191
|
+
index += 1
|
|
192
|
+
if index - start > best[1] - best[0]:
|
|
193
|
+
best = (start, index)
|
|
194
|
+
return best
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _text_lines(page, band, tally: dict, margins: list[str]) -> list[str]:
|
|
198
|
+
# Registered on the rendered strings: a column split turns one running head into two.
|
|
199
|
+
height = page.bbox[3] - page.bbox[1]
|
|
200
|
+
top, bottom = page.bbox[1] + MARGIN_BAND * height, page.bbox[3] - MARGIN_BAND * height
|
|
201
|
+
out = []
|
|
202
|
+
for entry in band:
|
|
203
|
+
text = sanitise(entry["text"], tally).strip()
|
|
204
|
+
if not text:
|
|
205
|
+
continue
|
|
206
|
+
if entry["top"] <= top or entry["bottom"] >= bottom:
|
|
207
|
+
margins.append(text)
|
|
208
|
+
out.append(text)
|
|
209
|
+
return out
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _layout_block(page, band, tally: dict) -> list[str]:
|
|
213
|
+
crop = _crop(page, min(line["x0"] for line in band) - 1, band[0]["top"] - 1,
|
|
214
|
+
max(line["x1"] for line in band) + 1, band[-1]["bottom"] + 1)
|
|
215
|
+
text = sanitise((crop or page).extract_text(layout=True) or "", tally, keep_layout=True)
|
|
216
|
+
rows = [row for row in text.split("\n") if row.strip()]
|
|
217
|
+
return ["```", *rows, "```"] if rows else []
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _caption_table(page, band, start: int, end: int, tally: dict) -> list[list] | None:
|
|
221
|
+
rows = band[start:end]
|
|
222
|
+
crop = _crop(page, min(line["x0"] for line in rows) - 1, rows[0]["top"] - 1,
|
|
223
|
+
max(line["x1"] for line in rows) + 1, rows[-1]["bottom"] + 1)
|
|
224
|
+
if crop is None:
|
|
225
|
+
return None
|
|
226
|
+
tables = crop.extract_tables(TEXT_TABLE)
|
|
227
|
+
if not tables:
|
|
228
|
+
return None
|
|
229
|
+
# The text strategy lays a row edge on every baseline, so widely leaded tables gain empty rows.
|
|
230
|
+
grid = [[sanitise(cell or "", tally).strip() for cell in row] for row in tables[0]]
|
|
231
|
+
grid = [row for row in grid if any(row)]
|
|
232
|
+
if len(grid) < MIN_TABULAR_LINES or max((len(row) for row in grid), default=0) < 2:
|
|
233
|
+
return None
|
|
234
|
+
return grid
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _band_blocks(page, band, flags: list[bool], tally: dict, margins: list[str],
|
|
238
|
+
columns: list[float] | None, source=None) -> list[tuple]:
|
|
239
|
+
source = source if source is not None else page
|
|
240
|
+
blocks = []
|
|
241
|
+
tally["text_regions"] += 1
|
|
242
|
+
start, end = _tabular_run(flags)
|
|
243
|
+
captioned = any(TABLE_CAPTION_RE.match(line["text"] or "") for line in band[:start])
|
|
244
|
+
# The caption route needs the tabular lines in ONE run; a table with wrapped rows has them on
|
|
245
|
+
# both sides of a prose line, and the rule grid reads that shape instead.
|
|
246
|
+
grid = (_caption_table(source, band, start, end, tally)
|
|
247
|
+
if captioned and end - start >= MIN_TABULAR_LINES and sum(flags) == end - start
|
|
248
|
+
else None)
|
|
249
|
+
if grid:
|
|
250
|
+
head = _text_lines(page, band[:start], tally, margins)
|
|
251
|
+
tail = _text_lines(page, band[end:], tally, margins)
|
|
252
|
+
blocks += [("text", head), ("table", grid), ("text", tail)]
|
|
253
|
+
tally["tables"] += 1
|
|
254
|
+
elif end - start >= MIN_TABULAR_LINES:
|
|
255
|
+
ruled = _rule_table(source, band, columns) if columns else None
|
|
256
|
+
if ruled:
|
|
257
|
+
first, last, cells = ruled
|
|
258
|
+
blocks += [("text", _text_lines(page, band[:first], tally, margins)),
|
|
259
|
+
("table", [[sanitise(cell, tally).strip() for cell in row]
|
|
260
|
+
for row in cells]),
|
|
261
|
+
("text", _text_lines(page, band[last:], tally, margins))]
|
|
262
|
+
tally["tables"] += 1
|
|
263
|
+
else:
|
|
264
|
+
blocks.append(("block", _layout_block(source, band, tally)))
|
|
265
|
+
else:
|
|
266
|
+
blocks.append(("text", _text_lines(page, band, tally, margins)))
|
|
267
|
+
return [block for block in blocks if block[1]]
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _text_region(page, crop, tally: dict, margins: list[str], wide_table: bool,
|
|
271
|
+
columns: list[float] | None = None, split: bool = True) -> list[tuple]:
|
|
272
|
+
# Bands first, then a gutter search inside each: a two-column half usually sits under a
|
|
273
|
+
# full-width abstract or table, and a whole-page histogram sees the two mixed.
|
|
274
|
+
lines = sorted(crop.extract_text_lines(), key=lambda line: (line["top"], line["x0"]))
|
|
275
|
+
if not lines:
|
|
276
|
+
return []
|
|
277
|
+
unit, blocks = space_unit(lines), []
|
|
278
|
+
for band in _bands(lines):
|
|
279
|
+
flags = [is_tabular(line, unit) for line in band]
|
|
280
|
+
start, end = _tabular_run(flags)
|
|
281
|
+
# A wide unlined table's column whitespace looks like a gutter; alignment across the
|
|
282
|
+
# band tells them apart.
|
|
283
|
+
tabular = aligned_columns(band, unit) >= MIN_SHARED_COLUMNS
|
|
284
|
+
chars = [char for line in band for char in line["chars"]]
|
|
285
|
+
gutters = _prose_gutters(band, page.width) if split and not wide_table else []
|
|
286
|
+
if gutters:
|
|
287
|
+
tally["columns_split"] += len(gutters)
|
|
288
|
+
bounds = [crop.bbox[0], *gutters, crop.bbox[2]]
|
|
289
|
+
for left, right in zip(bounds, bounds[1:]):
|
|
290
|
+
column = _crop(crop, left, band[0]["top"] - 1, right, max(line["bottom"] for line in band) + 1)
|
|
291
|
+
if column is not None and column.chars:
|
|
292
|
+
blocks += _text_region(page, column, tally, margins, False, columns, split=False)
|
|
293
|
+
continue
|
|
294
|
+
gutter = (detect_gutter(chars, page.width)
|
|
295
|
+
if split and not wide_table and not tabular else None)
|
|
296
|
+
if gutter is None:
|
|
297
|
+
blocks += _band_blocks(page, band, flags, tally, margins, columns, crop)
|
|
298
|
+
continue
|
|
299
|
+
tally["columns_split"] += 1
|
|
300
|
+
top, bottom = band[0]["top"] - 1, band[-1]["bottom"] + 1
|
|
301
|
+
for left, right in ((page.bbox[0], gutter), (gutter, page.bbox[2])):
|
|
302
|
+
column = _crop(crop, left, top, right, bottom)
|
|
303
|
+
if column is not None and column.chars:
|
|
304
|
+
blocks += _text_region(page, column, tally, margins, wide_table, columns,
|
|
305
|
+
split=False)
|
|
306
|
+
return blocks
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _page_tables(page) -> list:
|
|
310
|
+
"""Keep non-overlapping data regions, excluding single-column boxes and plot grids."""
|
|
311
|
+
kept = []
|
|
312
|
+
drawn = page.find_tables()
|
|
313
|
+
candidates = drawn + [table for table in _horizontal_tables(page)
|
|
314
|
+
if sum(_overlap(table._brewdoc_bounds, other.bbox) for other in drawn) < 2]
|
|
315
|
+
candidates.sort(key=lambda table: (getattr(table, "_brewdoc_bounds", table.bbox)[1],
|
|
316
|
+
-getattr(table, "_brewdoc_bounds", table.bbox)[3], table.bbox[0]))
|
|
317
|
+
for table in candidates:
|
|
318
|
+
xs, ys = _table_grid(page, table)
|
|
319
|
+
if len(xs) < 3 or len(ys) < 2:
|
|
320
|
+
continue
|
|
321
|
+
grid = _logical_rows(page, (xs[0], ys[0], xs[-1], ys[-1]), xs, table)
|
|
322
|
+
occupied = [sum(bool(cell.strip()) for cell in row) for row in grid]
|
|
323
|
+
if sum(count >= 2 for count in occupied) < 2:
|
|
324
|
+
continue
|
|
325
|
+
if any(_overlap(table.bbox, previous.bbox) for previous in kept):
|
|
326
|
+
continue
|
|
327
|
+
kept.append(table)
|
|
328
|
+
return kept
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _horizontal_tables(page) -> list:
|
|
332
|
+
"""Find repeated numeric records between matching horizontal rules without side borders."""
|
|
333
|
+
groups = []
|
|
334
|
+
for edge in page.edges:
|
|
335
|
+
if edge["orientation"] != "h" or edge["width"] <= 0:
|
|
336
|
+
continue
|
|
337
|
+
group = next((group for group in groups if abs(group[0]["x0"] - edge["x0"]) <= RULE_SNAP
|
|
338
|
+
and abs(group[0]["x1"] - edge["x1"]) <= RULE_SNAP), None)
|
|
339
|
+
if group is None:
|
|
340
|
+
group = []
|
|
341
|
+
groups.append(group)
|
|
342
|
+
group.append(edge)
|
|
343
|
+
found = []
|
|
344
|
+
for group in groups:
|
|
345
|
+
tops = _snap([edge["top"] for edge in group])
|
|
346
|
+
if len(tops) < 2:
|
|
347
|
+
continue
|
|
348
|
+
bbox = (group[0]["x0"], tops[0], group[0]["x1"], tops[-1])
|
|
349
|
+
crop = _crop(page, *bbox)
|
|
350
|
+
for table in crop.find_tables(TEXT_TABLE) if crop is not None else []:
|
|
351
|
+
if _numeric_records(table.extract()) < 2:
|
|
352
|
+
continue
|
|
353
|
+
table._brewdoc_bounds = bbox
|
|
354
|
+
table._brewdoc_columns = [column.bbox[0] for column in table.columns] + [table.bbox[2]]
|
|
355
|
+
found.append(table)
|
|
356
|
+
return found
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _numeric_records(grid) -> int:
|
|
360
|
+
"""Require multiple independently populated numeric fields for alignment-only grids."""
|
|
361
|
+
return sum(sum(bool(re.fullmatch(r"[+\-$]?\d[\d,.%]*", (cell or "").strip()))
|
|
362
|
+
for cell in row) >= 2 for row in grid)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _table_grid(page, table):
|
|
366
|
+
"""Refine sparse structural columns only with repeated numeric text alignment."""
|
|
367
|
+
if hasattr(table, "_brewdoc_grid"):
|
|
368
|
+
return table._brewdoc_grid
|
|
369
|
+
if hasattr(table, "_brewdoc_bounds"):
|
|
370
|
+
bbox = table._brewdoc_bounds
|
|
371
|
+
xs = [min(bbox[0], table._brewdoc_columns[0])] + table._brewdoc_columns[1:-1] + [max(bbox[2], table._brewdoc_columns[-1])]
|
|
372
|
+
ys = [bbox[1], bbox[3]]
|
|
373
|
+
else:
|
|
374
|
+
xs, ys = _implied_grid(_ruled_region(page, table.bbox))
|
|
375
|
+
if len(xs) < 2 or len(ys) < 2:
|
|
376
|
+
return xs, ys
|
|
377
|
+
crop = _crop(page, xs[0], ys[0], xs[-1], ys[-1])
|
|
378
|
+
if crop is not None and len(xs) <= 5:
|
|
379
|
+
candidates = crop.find_tables(TEXT_TABLE)
|
|
380
|
+
for candidate in candidates:
|
|
381
|
+
if len(candidate.columns) >= len(xs) and _numeric_records(candidate.extract()) >= 2:
|
|
382
|
+
xs = [xs[0]] + [column.bbox[0] for column in candidate.columns[1:]] + [xs[-1]]
|
|
383
|
+
if len(xs) <= 6 or hasattr(table, "_brewdoc_bounds"):
|
|
384
|
+
derived = _body_columns(page, (xs[0], ys[0], xs[-1], ys[-1]), xs)
|
|
385
|
+
if derived and len(derived) >= len(xs):
|
|
386
|
+
xs = derived
|
|
387
|
+
table._brewdoc_body_grid = True
|
|
388
|
+
for word in page.extract_words(x_tolerance_ratio=0.15):
|
|
389
|
+
if ys[0] <= (word["top"] + word["bottom"]) / 2 <= ys[-1]:
|
|
390
|
+
if word["x0"] < xs[0] < word["x1"]:
|
|
391
|
+
xs[0] = word["x0"]
|
|
392
|
+
if word["x0"] < xs[-1] < word["x1"]:
|
|
393
|
+
xs[-1] = word["x1"]
|
|
394
|
+
table._brewdoc_grid = xs, ys
|
|
395
|
+
return table._brewdoc_grid
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _body_columns(page, bbox, structural) -> list[float]:
|
|
399
|
+
"""Use repeated record anchors to retain close numeric fields and adjacent text stubs."""
|
|
400
|
+
words = [word for word in page.extract_words(x_tolerance_ratio=0.15)
|
|
401
|
+
if bbox[1] <= (word["top"] + word["bottom"]) / 2 <= bbox[3]]
|
|
402
|
+
if not words:
|
|
403
|
+
return []
|
|
404
|
+
height = statistics.median(word["bottom"] - word["top"] for word in words)
|
|
405
|
+
bands = []
|
|
406
|
+
for word in sorted(words, key=lambda item: (item["top"], item["x0"])):
|
|
407
|
+
if not bands or word["top"] - bands[-1][0]["top"] > max(1, height * 0.4):
|
|
408
|
+
bands.append([])
|
|
409
|
+
bands[-1].append(word)
|
|
410
|
+
numeric = re.compile(r"[+\-$]?\d[\d,./%]*(?:[A-Za-z*]+)?$")
|
|
411
|
+
records = [band for band in bands if sum(bool(numeric.fullmatch(word["text"]))
|
|
412
|
+
and bbox[0] <= (word["x0"] + word["x1"]) / 2 <= bbox[2] for word in band) >= 2]
|
|
413
|
+
if len(records) < 2:
|
|
414
|
+
return []
|
|
415
|
+
anchors = []
|
|
416
|
+
for row, band in enumerate(records):
|
|
417
|
+
groups = []
|
|
418
|
+
for word in sorted(band, key=lambda item: item["x0"]):
|
|
419
|
+
if (groups and word["x0"] - groups[-1]["x1"] <= height * 0.6
|
|
420
|
+
and not numeric.fullmatch(word["text"]) and not numeric.fullmatch(groups[-1]["text"])):
|
|
421
|
+
groups[-1]["text"] += " " + word["text"]
|
|
422
|
+
groups[-1]["x1"] = word["x1"]
|
|
423
|
+
else:
|
|
424
|
+
groups.append(dict(word))
|
|
425
|
+
for group in groups:
|
|
426
|
+
anchor = group["x1"] if numeric.fullmatch(group["text"]) else group["x0"]
|
|
427
|
+
anchors.append((anchor, row, group["x0"], group["x1"]))
|
|
428
|
+
clusters = []
|
|
429
|
+
for anchor in sorted(anchors):
|
|
430
|
+
if not clusters or anchor[0] - clusters[-1][0][0] > COLUMN_TOLERANCE:
|
|
431
|
+
clusters.append([])
|
|
432
|
+
clusters[-1].append(anchor)
|
|
433
|
+
stable = [group for group in clusters if len({anchor[1] for anchor in group}) >= max(2, len(records) * 0.5)]
|
|
434
|
+
stable = [group for group in stable if bbox[0] <= statistics.median(anchor[0] for anchor in group) <= bbox[2]
|
|
435
|
+
or len({anchor[1] for anchor in group}) >= len(records) * 0.75]
|
|
436
|
+
stable.sort(key=lambda group: statistics.median(anchor[2] for anchor in group))
|
|
437
|
+
if len(stable) < 2:
|
|
438
|
+
return []
|
|
439
|
+
intervals = [(statistics.median(anchor[2] for anchor in group), statistics.median(anchor[3] for anchor in group))
|
|
440
|
+
for group in stable]
|
|
441
|
+
gaps = [right[0] - left[1] for left, right in zip(intervals, intervals[1:]) if right[0] > left[1]]
|
|
442
|
+
reach = max(height * 4, statistics.median(gaps) * 3) if gaps else height * 4
|
|
443
|
+
intervals = [span for span in intervals if bbox[0] - reach <= span[0] and span[1] <= bbox[2] + reach]
|
|
444
|
+
if len(intervals) < 2:
|
|
445
|
+
return []
|
|
446
|
+
boundaries = [min(bbox[0], intervals[0][0] - 1)]
|
|
447
|
+
for left, right in zip(intervals, intervals[1:]):
|
|
448
|
+
if left[1] >= right[0]:
|
|
449
|
+
return []
|
|
450
|
+
candidates = [x for x in structural[1:-1] if left[1] < x < right[0]]
|
|
451
|
+
boundaries.append(min(candidates, key=lambda x: abs(x - (left[1] + right[0]) / 2))
|
|
452
|
+
if candidates else (left[1] + right[0]) / 2)
|
|
453
|
+
return boundaries + [max(bbox[2], intervals[-1][1] + 1)]
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _overlap(first, second) -> bool:
|
|
457
|
+
"""Return whether two page regions overlap in both dimensions."""
|
|
458
|
+
return (first[0] < second[2] and second[0] < first[2]
|
|
459
|
+
and first[1] < second[3] and second[1] < first[3])
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _structural_page(page):
|
|
463
|
+
"""Remove shading rectangles while retaining stroked borders and thin filled rules."""
|
|
464
|
+
return page.filter(lambda obj: obj["object_type"] != "rect" or obj.get("stroke")
|
|
465
|
+
or min(obj["width"], obj["height"]) <= RULE_SNAP)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _pdf_font_encodings(page) -> dict:
|
|
469
|
+
"""Read only explicit names in embedded Type1 encoding headers, never glyph programs."""
|
|
470
|
+
fonts = resolve1(page.page_obj.resources.get("Font", {}))
|
|
471
|
+
encodings, seen = {}, set()
|
|
472
|
+
for reference in fonts.values():
|
|
473
|
+
font = resolve1(reference)
|
|
474
|
+
name = getattr(font.get("BaseFont"), "name", "")
|
|
475
|
+
if name in seen:
|
|
476
|
+
encodings[name] = {}
|
|
477
|
+
continue
|
|
478
|
+
seen.add(name)
|
|
479
|
+
if "Encoding" in font:
|
|
480
|
+
continue
|
|
481
|
+
descriptor = resolve1(font.get("FontDescriptor", {}))
|
|
482
|
+
stream = resolve1(descriptor.get("FontFile"))
|
|
483
|
+
if not stream or not hasattr(stream, "get_data"):
|
|
484
|
+
continue
|
|
485
|
+
length = resolve1(stream.get("Length1", 0))
|
|
486
|
+
if not isinstance(length, int) or length <= 0:
|
|
487
|
+
continue
|
|
488
|
+
header = stream.get_data()[:length]
|
|
489
|
+
definitions = list(re.finditer(rb"/Encoding\s+256\s+array(.*?)\bdef\b", header, re.S))
|
|
490
|
+
if len(definitions) != 1:
|
|
491
|
+
continue
|
|
492
|
+
names = {}
|
|
493
|
+
for code, glyph in re.findall(rb"dup\s+(\d+)\s+/([A-Za-z0-9]+)\s+put", definitions[0][1]):
|
|
494
|
+
code = int(code)
|
|
495
|
+
names[code] = glyph.decode("ascii") if code not in names else None
|
|
496
|
+
encodings[name] = names
|
|
497
|
+
return encodings
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _pdf_page(page):
|
|
501
|
+
"""Prepare private character copies before any text cache, preserving source identities."""
|
|
502
|
+
encodings = _pdf_font_encodings(page)
|
|
503
|
+
prepared = page.filter(lambda obj: True)
|
|
504
|
+
chars = []
|
|
505
|
+
for index, source in enumerate(page.chars):
|
|
506
|
+
char = dict(source, _source_index=index, _source_text=source["text"], _unresolved=None)
|
|
507
|
+
text, font = char["text"], char["fontname"]
|
|
508
|
+
cid = CID_RE.fullmatch(text)
|
|
509
|
+
if cid:
|
|
510
|
+
code = int(text[5:-1])
|
|
511
|
+
glyph = encodings.get(font, {}).get(code)
|
|
512
|
+
char["text"] = PDF_FONT_GLYPHS.get(glyph, "[font-glyph:%s:%s]" % (font, code))
|
|
513
|
+
char["_unresolved"] = None if glyph in PDF_FONT_GLYPHS else "cid_survivors"
|
|
514
|
+
elif len(text) == 1 and unicodedata.category(text) == "Co":
|
|
515
|
+
char["text"] = "[font-glyph:%s:U+%04X]" % (font, ord(text))
|
|
516
|
+
char["_unresolved"] = "pua_glyphs"
|
|
517
|
+
else:
|
|
518
|
+
char["text"] = "".join(PDF_GLYPHS.get(glyph, glyph) for glyph in text)
|
|
519
|
+
chars.append(char)
|
|
520
|
+
prepared.objects["char"] = chars
|
|
521
|
+
return prepared
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _math_regions(page) -> list:
|
|
525
|
+
"""Find displayed math from source glyphs, neighboring tiers and fraction rules."""
|
|
526
|
+
prose = []
|
|
527
|
+
for line in page.extract_text_lines():
|
|
528
|
+
ink = [char for char in line["chars"] if char["text"].strip()]
|
|
529
|
+
ordinary = sum(char["_source_text"].isalpha()
|
|
530
|
+
and not any(name in char["fontname"] for name in ("CMMI", "CMSY", "CMEX", "Symbol"))
|
|
531
|
+
for char in ink)
|
|
532
|
+
if ordinary >= 35 and ordinary >= len(ink) * 0.6:
|
|
533
|
+
prose.append((line["x0"], line["top"], line["x1"], line["bottom"] + 2))
|
|
534
|
+
chars = [char for char in page.chars if char.get("upright", True) and char["text"].strip()
|
|
535
|
+
and not any(box[0] <= (char["x0"] + char["x1"]) / 2 <= box[2]
|
|
536
|
+
and box[1] <= (char["top"] + char["bottom"]) / 2 <= box[3] for box in prose)]
|
|
537
|
+
if not chars:
|
|
538
|
+
return []
|
|
539
|
+
height = statistics.median(char["height"] for char in chars)
|
|
540
|
+
tiers = []
|
|
541
|
+
for char in sorted(chars, key=lambda item: (item["top"], item["x0"])):
|
|
542
|
+
if not tiers or char["top"] - tiers[-1][0]["top"] > max(0.8, height * 0.2):
|
|
543
|
+
tiers.append([])
|
|
544
|
+
tiers[-1].append(char)
|
|
545
|
+
groups, current = [], []
|
|
546
|
+
for tier in tiers:
|
|
547
|
+
math = sum(not char["_unresolved"]
|
|
548
|
+
and (any(name in char["fontname"] for name in ("CMMI", "CMSY", "CMEX", "Symbol"))
|
|
549
|
+
or char.get("_source_text") in PDF_GLYPHS) for char in tier)
|
|
550
|
+
text = "".join(char["_source_text"] for char in sorted(tier, key=lambda item: item["x0"]))
|
|
551
|
+
dominant = math and (math * 3 >= len(tier) or len(tier) <= 12)
|
|
552
|
+
label = re.fullmatch(r"\(\d+\)", text) is not None
|
|
553
|
+
close = current and tier[0]["top"] - max(char["bottom"] for char in current[-1]) <= height * 2.5
|
|
554
|
+
if close:
|
|
555
|
+
previous_bottom = max(char["bottom"] for char in current[-1])
|
|
556
|
+
close = not any(previous_bottom < box[1] < tier[0]["top"] for box in prose)
|
|
557
|
+
ordinary = sum(char["_source_text"].isalpha() for char in tier)
|
|
558
|
+
folio = text.isdigit() and tier[0]["top"] > page.bbox[3] - page.height * MARGIN_BAND
|
|
559
|
+
short_tier = bool(current) and len(tier) <= 18 and not (not math and ordinary >= 8) and not folio
|
|
560
|
+
if (dominant or label or short_tier) and (not current or close):
|
|
561
|
+
current.append(tier)
|
|
562
|
+
else:
|
|
563
|
+
if current:
|
|
564
|
+
groups.append(current)
|
|
565
|
+
current = [tier] if dominant else []
|
|
566
|
+
if current:
|
|
567
|
+
groups.append(current)
|
|
568
|
+
regions = []
|
|
569
|
+
for group in groups:
|
|
570
|
+
ink = [char for tier in group for char in tier]
|
|
571
|
+
if len(group) < 2:
|
|
572
|
+
continue
|
|
573
|
+
bbox = (min(char["x0"] for char in ink), min(char["top"] for char in ink),
|
|
574
|
+
max(char["x1"] for char in ink), max(char["bottom"] for char in ink))
|
|
575
|
+
if bbox[2] - bbox[0] <= height * 4:
|
|
576
|
+
continue
|
|
577
|
+
bars = []
|
|
578
|
+
for edge in page.edges:
|
|
579
|
+
if edge["orientation"] != "h" or edge["width"] < 4 or edge["width"] >= bbox[2] - bbox[0]:
|
|
580
|
+
continue
|
|
581
|
+
y = edge["top"]
|
|
582
|
+
overlap = [char for char in ink if char["x0"] < edge["x1"] and char["x1"] > edge["x0"]]
|
|
583
|
+
if (any(char["bottom"] <= y for char in overlap) and any(char["top"] >= y for char in overlap)
|
|
584
|
+
and bbox[1] < y < bbox[3]
|
|
585
|
+
and not any(abs(y - bar["top"]) <= RULE_SNAP
|
|
586
|
+
and abs(edge["x0"] - bar["x0"]) <= RULE_SNAP for bar in bars)):
|
|
587
|
+
bars.append(edge)
|
|
588
|
+
if bars:
|
|
589
|
+
bbox = (min(bbox[0], min(bar["x0"] for bar in bars)), bbox[1],
|
|
590
|
+
max(bbox[2], max(bar["x1"] for bar in bars)), bbox[3])
|
|
591
|
+
regions.append((bbox, ink, bars))
|
|
592
|
+
return regions
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _math_block(chars, bars, tally: dict) -> list[str]:
|
|
596
|
+
"""Use one expanded x grid for every measured math tier and source fraction bar."""
|
|
597
|
+
unit = statistics.median(char["width"] for char in chars if char["width"] > 0) or 1
|
|
598
|
+
left = min([char["x0"] for char in chars] + [bar["x0"] for bar in bars])
|
|
599
|
+
rows = []
|
|
600
|
+
for char in sorted(chars, key=lambda item: (item["top"], item["x0"])):
|
|
601
|
+
if not rows or char["top"] - rows[-1][0] > 0.8:
|
|
602
|
+
rows.append((char["top"], []))
|
|
603
|
+
rows[-1][1].append((round((char["x0"] - left) / unit), char["text"]))
|
|
604
|
+
reservations = {}
|
|
605
|
+
for _, tokens in rows:
|
|
606
|
+
widths = Counter()
|
|
607
|
+
for anchor, text in tokens:
|
|
608
|
+
widths[anchor] += len(text)
|
|
609
|
+
for anchor, width in widths.items():
|
|
610
|
+
reservations[anchor] = max(reservations.get(anchor, 0), width)
|
|
611
|
+
for bar in bars:
|
|
612
|
+
for x in (bar["x0"], bar["x1"]):
|
|
613
|
+
reservations.setdefault(round((x - left) / unit), 0)
|
|
614
|
+
positions, previous, end = {}, 0, 0
|
|
615
|
+
for anchor in sorted(reservations):
|
|
616
|
+
positions[anchor] = max(anchor, end + max(0, anchor - previous - 1))
|
|
617
|
+
end = positions[anchor] + reservations[anchor]
|
|
618
|
+
previous = anchor
|
|
619
|
+
out = []
|
|
620
|
+
for y, tokens in rows:
|
|
621
|
+
text = ""
|
|
622
|
+
for anchor, token in sorted(tokens, key=lambda item: item[0]):
|
|
623
|
+
text += " " * max(0, positions[anchor] - len(text)) + token
|
|
624
|
+
out.append((y, sanitise(text, tally, keep_layout=True)))
|
|
625
|
+
for bar in bars:
|
|
626
|
+
start, stop = (positions[round((x - left) / unit)] for x in (bar["x0"], bar["x1"]))
|
|
627
|
+
out.append((bar["top"], " " * start + "-" * max(3, stop - start)))
|
|
628
|
+
return ["```", *(text for _, text in sorted(out, key=lambda item: item[0])), "```"]
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
# --- PDF: ruling the producer never finished --------------------------------------------------
|
|
632
|
+
def _snap(values) -> list[float]:
|
|
633
|
+
# Single-linkage on consecutive values: a separator drawn as overlapping segments stays one.
|
|
634
|
+
clusters: list[list[float]] = []
|
|
635
|
+
for value in sorted(values):
|
|
636
|
+
if clusters and value - clusters[-1][-1] <= RULE_SNAP:
|
|
637
|
+
clusters[-1].append(value)
|
|
638
|
+
else:
|
|
639
|
+
clusters.append([value])
|
|
640
|
+
return [statistics.median(cluster) for cluster in clusters]
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _ruled_region(page, bbox) -> list:
|
|
644
|
+
"""Follow connected rule segments without flooding through unrelated footer boxes."""
|
|
645
|
+
edges = page.edges
|
|
646
|
+
boxes = [(edge["x0"], edge["top"], edge["x1"], edge["bottom"]) for edge in edges]
|
|
647
|
+
cell_size = max(RULE_SNAP * 4, page.width / 32)
|
|
648
|
+
buckets = {}
|
|
649
|
+
|
|
650
|
+
def cells(box, padding=0):
|
|
651
|
+
"""List spatial buckets intersecting a segment's optional tolerance envelope."""
|
|
652
|
+
return ((x, y) for x in range(int((box[0] - padding) // cell_size), int((box[2] + padding) // cell_size) + 1)
|
|
653
|
+
for y in range(int((box[1] - padding) // cell_size), int((box[3] + padding) // cell_size) + 1))
|
|
654
|
+
|
|
655
|
+
for index, box in enumerate(boxes):
|
|
656
|
+
for cell in cells(box):
|
|
657
|
+
buckets.setdefault(cell, []).append(index)
|
|
658
|
+
|
|
659
|
+
def touches(first, second):
|
|
660
|
+
return (first[0] <= second[2] + RULE_SNAP and second[0] <= first[2] + RULE_SNAP
|
|
661
|
+
and first[1] <= second[3] + RULE_SNAP and second[1] <= first[3] + RULE_SNAP)
|
|
662
|
+
|
|
663
|
+
taken = {index for index, box in enumerate(boxes) if touches(box, bbox)}
|
|
664
|
+
pending = list(sorted(taken))
|
|
665
|
+
while pending:
|
|
666
|
+
current = pending.pop()
|
|
667
|
+
nearby = {index for cell in cells(boxes[current], RULE_SNAP) for index in buckets.get(cell, ())}
|
|
668
|
+
for index in sorted(nearby - taken):
|
|
669
|
+
if touches(boxes[current], boxes[index]):
|
|
670
|
+
taken.add(index)
|
|
671
|
+
pending.append(index)
|
|
672
|
+
return [edges[index] for index in sorted(taken)]
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _implied_grid(edges) -> tuple[list[float], list[float]]:
|
|
676
|
+
# A horizontal rule's ends are column boundaries, a vertical rule's ends row boundaries.
|
|
677
|
+
verticals = [edge["x0"] for edge in edges if edge["orientation"] == "v"]
|
|
678
|
+
horizontals = [edge["top"] for edge in edges if edge["orientation"] == "h"]
|
|
679
|
+
for edge in edges:
|
|
680
|
+
if edge["orientation"] == "h":
|
|
681
|
+
verticals += [edge["x0"], edge["x1"]]
|
|
682
|
+
else:
|
|
683
|
+
horizontals += [edge["top"], edge["bottom"]]
|
|
684
|
+
return _snap(verticals), _snap(horizontals)
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _fold_subscripts(source, box, text: str, chars=None) -> str:
|
|
688
|
+
# Centre-based ownership keeps a glyph crossing a rule in one cell.
|
|
689
|
+
# Subscript tolerance must not reach the next body line.
|
|
690
|
+
x0, top, x1, bottom = box
|
|
691
|
+
chars = source.chars if chars is None else chars
|
|
692
|
+
chars = [char for char in chars
|
|
693
|
+
if x0 <= (char["x0"] + char["x1"]) / 2 < x1
|
|
694
|
+
and top <= (char["top"] + char["bottom"]) / 2 < bottom]
|
|
695
|
+
ink = [char for char in chars if char["text"].strip()]
|
|
696
|
+
if not ink:
|
|
697
|
+
return text
|
|
698
|
+
body = max(char["size"] for char in ink)
|
|
699
|
+
small = [char for char in ink if char["size"] < SUBSCRIPT * body]
|
|
700
|
+
lines = sorted({round(char["top"], 1) for char in ink if char["size"] >= SUBSCRIPT * body})
|
|
701
|
+
if not small or not lines:
|
|
702
|
+
return text
|
|
703
|
+
reach = max(min(abs(char["top"] - line) for line in lines) for char in small) + 0.5
|
|
704
|
+
pitch = min((later - earlier for earlier, later in zip(lines, lines[1:])), default=reach + 1)
|
|
705
|
+
if reach >= pitch:
|
|
706
|
+
return text
|
|
707
|
+
return extract_text(chars, y_tolerance=reach) or text
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def _unused(lines, xs: list[float]) -> set[int]:
|
|
711
|
+
# Grid boundaries a row's own glyphs sit astride: running text, not a column. Only a glyph
|
|
712
|
+
# counts - a closed cell or an inter-word gap proves nothing.
|
|
713
|
+
chars = [char for line in lines for char in _line_chars(line)]
|
|
714
|
+
return {index for index in range(1, len(xs) - 1)
|
|
715
|
+
if any(char["x0"] < xs[index] < char["x1"] for char in chars)}
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def _unmerge(words, xs: list[float], cells: list[str], unused: set[int]) -> list[str]:
|
|
719
|
+
# Rebuilt from the words, not the cut cells, since a cut can fall inside a word.
|
|
720
|
+
if not unused:
|
|
721
|
+
return list(cells)
|
|
722
|
+
out, index = [], 0
|
|
723
|
+
while index < len(cells):
|
|
724
|
+
end = index + 1
|
|
725
|
+
while end < len(cells) and end in unused:
|
|
726
|
+
end += 1
|
|
727
|
+
if end == index + 1:
|
|
728
|
+
out.append(cells[index])
|
|
729
|
+
index = end
|
|
730
|
+
continue
|
|
731
|
+
left, right = xs[index] - 0.5, xs[min(end, len(xs) - 1)] + 0.5
|
|
732
|
+
out.append(" ".join(word["text"] for word in words
|
|
733
|
+
if left <= word["x0"] and word["x1"] <= right))
|
|
734
|
+
out += [""] * (end - index - 1)
|
|
735
|
+
index = end
|
|
736
|
+
return out
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _row_cells(source, row, raw: list) -> list[str]:
|
|
740
|
+
return [_fold_subscripts(source, box, cell) if box and cell else (cell or "")
|
|
741
|
+
for box, cell in zip(row.cells, raw)]
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def _table_cells(source, table, xs: list[float] | None = None) -> list[list[str]]:
|
|
745
|
+
raw = table.extract()
|
|
746
|
+
lines = source.extract_text_lines() if xs else []
|
|
747
|
+
words = source.extract_words() if xs else []
|
|
748
|
+
grid = []
|
|
749
|
+
for index, row in enumerate(table.rows):
|
|
750
|
+
cells = _row_cells(source, row, raw[index])
|
|
751
|
+
if xs:
|
|
752
|
+
top, bottom = row.bbox[1] - 1, row.bbox[3] + 1
|
|
753
|
+
|
|
754
|
+
def within(items):
|
|
755
|
+
return [item for item in items
|
|
756
|
+
if item["top"] >= top and item["bottom"] <= bottom]
|
|
757
|
+
cells = _unmerge(within(words), xs, cells, _unused(within(lines), xs))
|
|
758
|
+
grid.append(cells)
|
|
759
|
+
return grid
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def _trim(grid: list[list[str]]) -> list[list[str]]:
|
|
763
|
+
width = max((len(row) for row in grid), default=0)
|
|
764
|
+
rows = [row + [""] * (width - len(row)) for row in grid]
|
|
765
|
+
keep = [index for index in range(width) if any(row[index].strip() for row in rows)]
|
|
766
|
+
return [[row[index] for index in keep] for row in rows if any(cell.strip() for cell in row)]
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def _repair_table(page, table):
|
|
770
|
+
"""Read source baselines on the structural grid, assigning each word exactly once."""
|
|
771
|
+
xs, ys = _table_grid(page, table)
|
|
772
|
+
if len(xs) < 3 or len(ys) < 2:
|
|
773
|
+
return None
|
|
774
|
+
bbox = (xs[0], ys[0], xs[-1], ys[-1])
|
|
775
|
+
grid = _logical_rows(page, bbox, xs, table)
|
|
776
|
+
return (grid, bbox) if grid else None
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def _logical_rows(page, bbox, xs, table=None) -> list[list[str]]:
|
|
780
|
+
"""Separate data baselines and attach only compatible wrapped cell continuations."""
|
|
781
|
+
crop = page.filter(lambda obj: obj["object_type"] != "char"
|
|
782
|
+
or (bbox[0] <= (obj["x0"] + obj["x1"]) / 2 < bbox[2]
|
|
783
|
+
and bbox[1] <= (obj["top"] + obj["bottom"]) / 2 < bbox[3]))
|
|
784
|
+
words = crop.extract_words(x_tolerance_ratio=0.2, return_chars=True)
|
|
785
|
+
words = [word for word in words if bbox[0] <= (word["x0"] + word["x1"]) / 2 <= bbox[2]
|
|
786
|
+
and bbox[1] <= (word["top"] + word["bottom"]) / 2 <= bbox[3]]
|
|
787
|
+
if not words:
|
|
788
|
+
return []
|
|
789
|
+
height = statistics.median(word["bottom"] - word["top"] for word in words)
|
|
790
|
+
tolerance = max(1.0, height * 0.4)
|
|
791
|
+
bands = []
|
|
792
|
+
for word in sorted(words, key=lambda item: (item["top"], item["x0"])):
|
|
793
|
+
if not bands or abs(word["top"] - bands[-1][0]["top"]) > tolerance:
|
|
794
|
+
bands.append([])
|
|
795
|
+
bands[-1].append(word)
|
|
796
|
+
def column_at(x):
|
|
797
|
+
return min(len(xs) - 2, sum(x >= boundary for boundary in xs[1:-1]))
|
|
798
|
+
|
|
799
|
+
def body_band(band):
|
|
800
|
+
"""Separate populated numeric records from bold or oversized header baselines."""
|
|
801
|
+
chars = [char for word in band for char in word["chars"]]
|
|
802
|
+
numeric = any(re.fullmatch(r"[+\-$]?\d[\d,./%]*", word["text"]) for word in band)
|
|
803
|
+
occupied = {column_at((word["x0"] + word["x1"]) / 2) for word in band}
|
|
804
|
+
return (numeric and len(occupied) >= 2
|
|
805
|
+
and not all("bold" in char["fontname"].lower() for char in chars)
|
|
806
|
+
and statistics.median(char["size"] for char in chars) <= height * 1.25)
|
|
807
|
+
|
|
808
|
+
body_top = next((band[0]["top"] for band in bands if body_band(band)), bands[0][0]["top"])
|
|
809
|
+
verticals = [edge for edge in page.edges if edge["orientation"] == "v"]
|
|
810
|
+
inferred = table is not None and getattr(table, "_brewdoc_body_grid", False)
|
|
811
|
+
horizontal = table is not None and hasattr(table, "_brewdoc_bounds")
|
|
812
|
+
centres, body_gaps = {}, {}
|
|
813
|
+
if horizontal: # body cell spans per column: they anchor the header heads above them
|
|
814
|
+
spans = []
|
|
815
|
+
for band in bands:
|
|
816
|
+
if band[0]["top"] < body_top:
|
|
817
|
+
continue
|
|
818
|
+
cells = {}
|
|
819
|
+
for word in band:
|
|
820
|
+
column = column_at(word["x0"] + 1 if inferred else (word["x0"] + word["x1"]) / 2)
|
|
821
|
+
low, high = cells.get(column, (word["x0"], word["x1"]))
|
|
822
|
+
cells[column] = (min(low, word["x0"]), max(high, word["x1"]))
|
|
823
|
+
spans.append(cells)
|
|
824
|
+
for column in {index for cells in spans for index in cells}:
|
|
825
|
+
centres[column] = statistics.median((cells[column][0] + cells[column][1]) / 2
|
|
826
|
+
for cells in spans if column in cells)
|
|
827
|
+
body_gaps[column] = min((cells[column + 1][0] - cells[column][1] for cells in spans
|
|
828
|
+
if column in cells and column + 1 in cells), default=0.0)
|
|
829
|
+
|
|
830
|
+
def headed(word):
|
|
831
|
+
"""Body column a header span is centred over, or None when it is centred over none."""
|
|
832
|
+
centre = (word["x0"] + word["x1"]) / 2
|
|
833
|
+
column = min(centres, key=lambda index: abs(centres[index] - centre), default=None)
|
|
834
|
+
return column if column is not None and abs(centres[column] - centre) <= COLUMN_TOLERANCE else None
|
|
835
|
+
|
|
836
|
+
def recover_header(word, gaps):
|
|
837
|
+
"""Split only one supported source gap into two complete, body-centred heads."""
|
|
838
|
+
if not inferred or headed(word) is not None:
|
|
839
|
+
return [word]
|
|
840
|
+
chars, candidates = word["chars"], []
|
|
841
|
+
for index, (left_char, right_char) in enumerate(zip(chars, chars[1:]), 1):
|
|
842
|
+
if (id(left_char), id(right_char)) not in gaps:
|
|
843
|
+
continue
|
|
844
|
+
fragments = [dict(word, chars=part, x0=min(char["x0"] for char in part),
|
|
845
|
+
x1=max(char["x1"] for char in part), top=min(char["top"] for char in part),
|
|
846
|
+
bottom=max(char["bottom"] for char in part))
|
|
847
|
+
for part in (chars[:index], chars[index:])]
|
|
848
|
+
left, right = map(headed, fragments)
|
|
849
|
+
if left is not None and right == left + 1 and body_gaps[left] > height * 0.4:
|
|
850
|
+
candidates.append(fragments)
|
|
851
|
+
if len(candidates) != 1:
|
|
852
|
+
return [word]
|
|
853
|
+
for fragment in candidates[0]:
|
|
854
|
+
fragment["text"] = extract_text(fragment["chars"], x_tolerance_ratio=0.2)
|
|
855
|
+
return candidates[0]
|
|
856
|
+
|
|
857
|
+
rows = []
|
|
858
|
+
for band in bands:
|
|
859
|
+
ordered_words = sorted(band, key=lambda item: item["x0"])
|
|
860
|
+
header = horizontal and band[0]["top"] < body_top
|
|
861
|
+
if header:
|
|
862
|
+
# Contiguous character edges can differ by sub-micropoint rounding residue.
|
|
863
|
+
gaps = {(id(left), id(right)) for word in ordered_words
|
|
864
|
+
for left, right in zip(word["chars"], word["chars"][1:])
|
|
865
|
+
if right["x0"] - left["x1"] > 1e-6}
|
|
866
|
+
grouped = []
|
|
867
|
+
for word in ordered_words:
|
|
868
|
+
aim = headed(grouped[-1]) if grouped else None
|
|
869
|
+
# a head aimed further right keeps its own cell, unless the body columns touch
|
|
870
|
+
split = (aim is not None and body_gaps[aim] > height * 0.4
|
|
871
|
+
and column_at((word["x0"] + word["x1"]) / 2) > aim)
|
|
872
|
+
if grouped and word["x0"] - grouped[-1]["x1"] <= height * 0.4 and not split:
|
|
873
|
+
grouped[-1]["text"] += " " + word["text"]
|
|
874
|
+
grouped[-1]["x1"] = word["x1"]
|
|
875
|
+
grouped[-1]["chars"] += word["chars"]
|
|
876
|
+
else:
|
|
877
|
+
grouped.append(dict(word, chars=list(word["chars"])))
|
|
878
|
+
ordered_words = [fragment for word in grouped for fragment in recover_header(word, gaps)]
|
|
879
|
+
middle = statistics.median((word["top"] + word["bottom"]) / 2 for word in band)
|
|
880
|
+
unused = {index for index, boundary in enumerate(xs[1:-1], 1)
|
|
881
|
+
if not any(abs(edge["x0"] - boundary) <= RULE_SNAP
|
|
882
|
+
and edge["top"] <= middle <= edge["bottom"] for edge in verticals)
|
|
883
|
+
and (any(word["x0"] + 1 < boundary < word["x1"] - 1
|
|
884
|
+
and re.search(r"[A-Za-z]", word["text"]) for word in band)
|
|
885
|
+
or any(left["x1"] <= boundary <= right["x0"]
|
|
886
|
+
and right["x0"] - left["x1"] <= height * 0.6
|
|
887
|
+
and re.search(r"[A-Za-z]", left["text"]) and re.search(r"[A-Za-z]", right["text"])
|
|
888
|
+
for left, right in zip(ordered_words, ordered_words[1:])))}
|
|
889
|
+
for word in ordered_words:
|
|
890
|
+
x = (word["x0"] + word["x1"]) / 2
|
|
891
|
+
y = (word["top"] + word["bottom"]) / 2
|
|
892
|
+
column = column_at(word["x0"] + 1) if inferred else column_at(x)
|
|
893
|
+
if header and (aim := headed(word)) is not None:
|
|
894
|
+
column = aim
|
|
895
|
+
anchor = band[0]["top"]
|
|
896
|
+
if table is not None and not horizontal and word["top"] < body_top:
|
|
897
|
+
separators = _snap([bbox[0], bbox[2]] + [edge["x0"] for edge in verticals
|
|
898
|
+
if bbox[0] <= edge["x0"] <= bbox[2]
|
|
899
|
+
and edge["top"] <= y <= edge["bottom"]])
|
|
900
|
+
left = max(boundary for boundary in separators if boundary <= x)
|
|
901
|
+
right = min(boundary for boundary in separators if boundary > x)
|
|
902
|
+
column = min(range(len(xs) - 1), key=lambda index: abs(xs[index] - left))
|
|
903
|
+
boxes = [box for box in table.cells if box[0] <= x < box[2]
|
|
904
|
+
and box[1] <= y < box[3] and abs(box[0] - left) <= RULE_SNAP
|
|
905
|
+
and abs(box[2] - right) <= RULE_SNAP]
|
|
906
|
+
box = min(boxes, key=lambda box: (box[2] - box[0]) * (box[3] - box[1])) if boxes else None
|
|
907
|
+
if box is not None:
|
|
908
|
+
anchor = box[1]
|
|
909
|
+
elif not inferred:
|
|
910
|
+
while column in unused:
|
|
911
|
+
column -= 1
|
|
912
|
+
row = next((row for row in rows if abs(row["anchor"] - anchor) <= tolerance), None)
|
|
913
|
+
if row is None:
|
|
914
|
+
row = {"cells": [""] * (len(xs) - 1), "anchor": anchor, "top": word["top"],
|
|
915
|
+
"bottom": word["bottom"], "standalone": False, "left": {}, "chars": {}}
|
|
916
|
+
rows.append(row)
|
|
917
|
+
row["cells"][column] = (row["cells"][column] + " " + word["text"]).strip()
|
|
918
|
+
row["chars"].setdefault(column, []).extend(word["chars"])
|
|
919
|
+
row["left"][column] = min(row["left"].get(column, word["x0"]), word["x0"])
|
|
920
|
+
row["top"] = min(row["top"], word["top"])
|
|
921
|
+
row["bottom"] = max(row["bottom"], word["bottom"])
|
|
922
|
+
row["standalone"] |= any("bold" in char["fontname"].lower() or char["size"] > height * 1.25
|
|
923
|
+
for char in word["chars"])
|
|
924
|
+
rows.sort(key=lambda row: row["anchor"])
|
|
925
|
+
out, owned = [], []
|
|
926
|
+
for index, row in enumerate(rows):
|
|
927
|
+
filled = [column for column, cell in enumerate(row["cells"]) if cell]
|
|
928
|
+
previous = rows[index - 1] if index else None
|
|
929
|
+
following = rows[index + 1] if index + 1 < len(rows) else None
|
|
930
|
+
if len(filled) == 1 and not row["standalone"]:
|
|
931
|
+
column = filled[0]
|
|
932
|
+
if (out and previous and row["top"] - previous["bottom"] <= height
|
|
933
|
+
and out[-1][column] and sum(bool(cell) for cell in out[-1]) >= 2
|
|
934
|
+
and abs(row["left"][column] - previous["left"].get(column, row["left"][column])) <= height * 3
|
|
935
|
+
and not any(edge["orientation"] == "h" and previous["bottom"] <= edge["top"] <= row["top"]
|
|
936
|
+
and edge["x0"] <= xs[column] + RULE_SNAP
|
|
937
|
+
and edge["x1"] >= xs[column + 1] - RULE_SNAP for edge in page.edges)):
|
|
938
|
+
out[-1][column] += " " + row["cells"][column]
|
|
939
|
+
owned[-1].setdefault(column, []).extend(row["chars"][column])
|
|
940
|
+
continue
|
|
941
|
+
if (not column and following and following["top"] - row["bottom"] <= height
|
|
942
|
+
and following["cells"][column] and sum(bool(cell) for cell in following["cells"]) >= 2):
|
|
943
|
+
following["cells"][column] = row["cells"][column] + " " + following["cells"][column]
|
|
944
|
+
following["chars"].setdefault(column, []).extend(row["chars"][column])
|
|
945
|
+
continue
|
|
946
|
+
out.append(row["cells"])
|
|
947
|
+
owned.append(row["chars"])
|
|
948
|
+
for cells, columns in zip(out, owned):
|
|
949
|
+
for column, chars in columns.items():
|
|
950
|
+
cells[column] = _fold_subscripts(page, bbox, cells[column], chars)
|
|
951
|
+
return out
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
def _one_rule(edges: list) -> bool:
|
|
955
|
+
"""True when the edges are one segmented rule, not separate figures sharing a baseline."""
|
|
956
|
+
ordered = sorted(edges, key=lambda edge: edge["x0"])
|
|
957
|
+
reach = ordered[0]["x1"]
|
|
958
|
+
for edge in ordered[1:]:
|
|
959
|
+
if edge["x0"] > reach + RULE_SNAP:
|
|
960
|
+
return False
|
|
961
|
+
reach = max(reach, edge["x1"])
|
|
962
|
+
return True
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
def _rule_columns(page) -> tuple[list[float], float, float] | None:
|
|
966
|
+
# A rule emitted as one segment per column carries the grid in its segment ends. The opening
|
|
967
|
+
# rule is the topmost of the same width: caption and running head sit above it.
|
|
968
|
+
groups: dict[int, list] = {}
|
|
969
|
+
for edge in page.edges:
|
|
970
|
+
if edge["orientation"] == "h":
|
|
971
|
+
groups.setdefault(int(edge["top"] / RULE_SNAP), []).append(edge)
|
|
972
|
+
groups = {key: group for key, group in groups.items() if _one_rule(group)}
|
|
973
|
+
spans = {key: _snap([edge["x0"] for edge in group] + [edge["x1"] for edge in group])
|
|
974
|
+
for key, group in groups.items()}
|
|
975
|
+
best = None
|
|
976
|
+
for key in sorted(spans):
|
|
977
|
+
if len(spans[key]) >= MIN_RULE_COLUMNS and (best is None or len(spans[key]) > len(best[0])):
|
|
978
|
+
best = (spans[key], key)
|
|
979
|
+
if best is None:
|
|
980
|
+
return None
|
|
981
|
+
xs, chosen = best
|
|
982
|
+
same = [key for key, span in spans.items()
|
|
983
|
+
if abs(span[0] - xs[0]) <= RULE_SNAP and abs(span[-1] - xs[-1]) <= RULE_SNAP]
|
|
984
|
+
opening = min(min(edge["top"] for edge in groups[key]) for key in same if key <= chosen)
|
|
985
|
+
closing = max(max(edge["bottom"] for edge in groups[key]) for key in same)
|
|
986
|
+
return xs, opening, closing
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
def _rule_rows(band, xs: list[float], opening: float) -> tuple[int, int]:
|
|
990
|
+
# Past a page break the continuation carries only the closing rule, so try below first.
|
|
991
|
+
def fits(line):
|
|
992
|
+
return line["x0"] >= xs[0] - RULE_SNAP and line["x1"] <= xs[-1] + RULE_SNAP
|
|
993
|
+
below = _tabular_run([fits(line) and line["top"] >= opening - RULE_SNAP for line in band])
|
|
994
|
+
if below[1] - below[0] >= MIN_TABULAR_LINES:
|
|
995
|
+
return below
|
|
996
|
+
return _tabular_run([fits(line) and line["bottom"] <= opening + RULE_SNAP for line in band])
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
def _rule_table(page, band, columns: tuple[list[float], float, float]):
|
|
1000
|
+
# Must adjoin the ruling: short prose lines fit any grid. Outer boundaries come from the
|
|
1001
|
+
# words, since pdfplumber drops a column whose boundary lies beyond the last word.
|
|
1002
|
+
grid, opening, closing = columns
|
|
1003
|
+
reach = BLANK_BAND * (statistics.median([line["bottom"] - line["top"] for line in band]) or 1.0)
|
|
1004
|
+
if band[0]["top"] - closing > reach or opening - band[-1]["bottom"] > reach:
|
|
1005
|
+
return None
|
|
1006
|
+
start, end = _rule_rows(band, grid, opening)
|
|
1007
|
+
rows = band[start:end]
|
|
1008
|
+
if len(rows) < MIN_TABULAR_LINES:
|
|
1009
|
+
return None
|
|
1010
|
+
left, right = min(line["x0"] for line in rows), max(line["x1"] for line in rows)
|
|
1011
|
+
inner = [x for x in grid if left + RULE_SNAP < x < right - RULE_SNAP]
|
|
1012
|
+
if len(inner) < MIN_RULE_COLUMNS - 2:
|
|
1013
|
+
return None
|
|
1014
|
+
crop = _crop(page, left - 1, rows[0]["top"] - 1, right + 1, rows[-1]["bottom"] + 1)
|
|
1015
|
+
words = crop.extract_words() if crop is not None else []
|
|
1016
|
+
if not words:
|
|
1017
|
+
return None
|
|
1018
|
+
xs = [min(word["x0"] for word in words)] + inner + [max(word["x1"] for word in words)]
|
|
1019
|
+
found = crop.find_tables({"vertical_strategy": "explicit", "horizontal_strategy": "text",
|
|
1020
|
+
"explicit_vertical_lines": xs})
|
|
1021
|
+
if not found:
|
|
1022
|
+
return None
|
|
1023
|
+
cells = _trim(_table_cells(crop, found[0], xs))
|
|
1024
|
+
# A real table fills three or more columns on roughly half its rows; prose beside a rule
|
|
1025
|
+
# reaches a few percent and belongs in a fixed-width block.
|
|
1026
|
+
gridded = sum(1 for row in cells if sum(1 for cell in row if cell.strip()) >= GRID_ROW_COLUMNS)
|
|
1027
|
+
if not cells or gridded * GRID_ROW_SHARE < len(cells):
|
|
1028
|
+
return None
|
|
1029
|
+
return start, end, cells
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
def render_page(page, tally: dict) -> tuple[list[tuple], list[str]]:
|
|
1033
|
+
"""Render qualified table boxes and preserve the text beside them in column order."""
|
|
1034
|
+
page = _structural_page(_pdf_page(page))
|
|
1035
|
+
mathematics = _math_regions(page)
|
|
1036
|
+
math_chars = {char["_source_index"] for _, chars, _ in mathematics for char in chars}
|
|
1037
|
+
math_bars = [bar for _, _, bars in mathematics for bar in bars]
|
|
1038
|
+
|
|
1039
|
+
def outside_math(obj):
|
|
1040
|
+
"""Reserve only the characters and source bars owned by displayed math."""
|
|
1041
|
+
if obj["object_type"] == "char":
|
|
1042
|
+
return obj["_source_index"] not in math_chars
|
|
1043
|
+
return not (obj["object_type"] in ("line", "rect") and obj["height"] <= RULE_SNAP
|
|
1044
|
+
and any(abs(obj["top"] - bar["top"]) <= RULE_SNAP
|
|
1045
|
+
and abs(obj["x0"] - bar["x0"]) <= RULE_SNAP
|
|
1046
|
+
and abs(obj["x1"] - bar["x1"]) <= RULE_SNAP for bar in math_bars))
|
|
1047
|
+
|
|
1048
|
+
page = page.filter(outside_math)
|
|
1049
|
+
tables = _page_tables(page)
|
|
1050
|
+
columns, margins = _rule_columns(page), []
|
|
1051
|
+
regions = [(bbox, (chars, bars), "math") for bbox, chars, bars in mathematics]
|
|
1052
|
+
for table in tables:
|
|
1053
|
+
repaired = _repair_table(page, table)
|
|
1054
|
+
cells, bbox = repaired if repaired else (_table_cells(page, table), table.bbox)
|
|
1055
|
+
if not any(_overlap(bbox, previous[0]) for previous in regions):
|
|
1056
|
+
regions.append((bbox, cells, "table"))
|
|
1057
|
+
|
|
1058
|
+
counted = set()
|
|
1059
|
+
|
|
1060
|
+
def count_chars(chars):
|
|
1061
|
+
"""Count unresolved glyphs only at final source-character ownership."""
|
|
1062
|
+
for char in chars:
|
|
1063
|
+
index = char["_source_index"]
|
|
1064
|
+
if index not in counted and char["_unresolved"]:
|
|
1065
|
+
tally["dropped"][char["_unresolved"]] += 1
|
|
1066
|
+
counted.add(index)
|
|
1067
|
+
|
|
1068
|
+
def crop_region(box):
|
|
1069
|
+
"""Assign boundary-crossing characters by their centers before clipping geometry."""
|
|
1070
|
+
filtered = page.filter(lambda obj: obj["object_type"] != "char" or (
|
|
1071
|
+
box[0] <= (obj["x0"] + obj["x1"]) / 2 < box[2]
|
|
1072
|
+
and box[1] <= (obj["top"] + obj["bottom"]) / 2 < box[3]))
|
|
1073
|
+
return _crop(filtered, *box)
|
|
1074
|
+
|
|
1075
|
+
def text_region(box):
|
|
1076
|
+
"""Emit residual text from the prepared view without restoring excluded objects."""
|
|
1077
|
+
crop = crop_region(box)
|
|
1078
|
+
if crop is not None:
|
|
1079
|
+
count_chars(crop.chars)
|
|
1080
|
+
return (_text_region(page, crop, tally, margins, False, columns)
|
|
1081
|
+
if crop is not None and crop.chars else [])
|
|
1082
|
+
|
|
1083
|
+
def object_block(box, cells, kind):
|
|
1084
|
+
"""Emit one table or math object and account for its source characters once."""
|
|
1085
|
+
if kind == "math":
|
|
1086
|
+
count_chars(cells[0])
|
|
1087
|
+
tally["text_regions"] += 1
|
|
1088
|
+
return text_region(box) + [("math", _math_block(*cells, tally))]
|
|
1089
|
+
crop = crop_region(box)
|
|
1090
|
+
if crop is not None:
|
|
1091
|
+
count_chars(crop.chars)
|
|
1092
|
+
tally["tables"] += 1
|
|
1093
|
+
return [("table", [[sanitise(cell, tally).strip() for cell in row] for row in cells])]
|
|
1094
|
+
|
|
1095
|
+
def render_region(box, items):
|
|
1096
|
+
"""Partition disjoint columns before carving stacked objects within each column."""
|
|
1097
|
+
if not items:
|
|
1098
|
+
return text_region(box)
|
|
1099
|
+
crop = crop_region(box)
|
|
1100
|
+
gutter = detect_gutter(crop.chars, box[2] - box[0]) if crop is not None else None
|
|
1101
|
+
if (gutter is not None and all(not (bbox[0] < gutter < bbox[2]) for bbox, _, _ in items)
|
|
1102
|
+
and all(bbox[2] - bbox[0] < WIDE_TABLE * (box[2] - box[0]) for bbox, _, _ in items)):
|
|
1103
|
+
tally["columns_split"] += 1
|
|
1104
|
+
return (render_region((box[0], box[1], gutter, box[3]),
|
|
1105
|
+
[item for item in items if item[0][2] <= gutter])
|
|
1106
|
+
+ render_region((gutter, box[1], box[2], box[3]),
|
|
1107
|
+
[item for item in items if item[0][0] >= gutter]))
|
|
1108
|
+
ordered = sorted(items, key=lambda item: (item[0][1], item[0][0]))
|
|
1109
|
+
top, bottom = ordered[0][0][1], ordered[0][0][3]
|
|
1110
|
+
group = []
|
|
1111
|
+
for item in ordered:
|
|
1112
|
+
if item[0][1] >= bottom and group:
|
|
1113
|
+
break
|
|
1114
|
+
group.append(item)
|
|
1115
|
+
bottom = max(bottom, item[0][3])
|
|
1116
|
+
blocks = text_region((box[0], box[1], box[2], top))
|
|
1117
|
+
groups = []
|
|
1118
|
+
for item in sorted(group, key=lambda item: item[0][0]):
|
|
1119
|
+
if not groups or item[0][0] >= max(other[0][2] for other in groups[-1]):
|
|
1120
|
+
groups.append([])
|
|
1121
|
+
groups[-1].append(item)
|
|
1122
|
+
if len(groups) > 1 and any(len(column) > 1 for column in groups):
|
|
1123
|
+
left = box[0]
|
|
1124
|
+
for column in groups:
|
|
1125
|
+
start = min(item[0][0] for item in column)
|
|
1126
|
+
end = max(item[0][2] for item in column)
|
|
1127
|
+
blocks += text_region((left, top, start, bottom))
|
|
1128
|
+
blocks += render_region((start, top, end, bottom), column)
|
|
1129
|
+
left = end
|
|
1130
|
+
blocks += text_region((left, top, box[2], bottom))
|
|
1131
|
+
return blocks + render_region((box[0], bottom, box[2], box[3]), ordered[len(group):])
|
|
1132
|
+
left = box[0]
|
|
1133
|
+
for bbox, cells, kind in sorted(group, key=lambda item: item[0][0]):
|
|
1134
|
+
blocks += text_region((left, top, bbox[0], bottom))
|
|
1135
|
+
blocks += text_region((bbox[0], top, bbox[2], bbox[1]))
|
|
1136
|
+
blocks += object_block(bbox, cells, kind)
|
|
1137
|
+
blocks += text_region((bbox[0], bbox[3], bbox[2], bottom))
|
|
1138
|
+
left = bbox[2]
|
|
1139
|
+
blocks += text_region((left, top, box[2], bottom))
|
|
1140
|
+
blocks += render_region((box[0], bottom, box[2], box[3]), ordered[len(group):])
|
|
1141
|
+
return blocks
|
|
1142
|
+
|
|
1143
|
+
blocks = render_region(page.bbox, regions)
|
|
1144
|
+
return [block for block in blocks if block[1]], margins
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
def furniture(margins: list[list[str]]) -> tuple[set[str], set[str]]:
|
|
1148
|
+
"""(running heads as head_key forms, page numbers) from every page's margin candidates."""
|
|
1149
|
+
counts = Counter(head_key(text) for page in margins for text in set(page))
|
|
1150
|
+
numbers = {text for page in margins for text in page if PAGE_NUMBER_RE.match(text)}
|
|
1151
|
+
heads = {key for key, count in counts.items() if count >= REPEATS_AS_HEAD}
|
|
1152
|
+
return heads, numbers
|
|
1153
|
+
|
|
1154
|
+
|
|
1155
|
+
def _render_pdf(path: Path, _sheets=None) -> Rendered:
|
|
1156
|
+
tally = new_tally()
|
|
1157
|
+
pages, margins, empty = [], [], 0
|
|
1158
|
+
with reading(path, "pdf"), pdfplumber.open(path) as pdf:
|
|
1159
|
+
for page in pdf.pages:
|
|
1160
|
+
if not page.chars:
|
|
1161
|
+
empty += 1
|
|
1162
|
+
blocks, page_margins = render_page(page, tally)
|
|
1163
|
+
pages.append(blocks)
|
|
1164
|
+
margins.append(page_margins)
|
|
1165
|
+
page.flush_cache() # a long book otherwise holds every char object
|
|
1166
|
+
if pages and empty == len(pages):
|
|
1167
|
+
raise BrewdocError(
|
|
1168
|
+
"no text layer: %d of %d pages carry zero characters in %s - this reader does no OCR"
|
|
1169
|
+
% (empty, len(pages), path))
|
|
1170
|
+
heads, numbers = furniture(margins)
|
|
1171
|
+
units = [(number, "Page %d" % number, blocks)
|
|
1172
|
+
for number, blocks in enumerate(pages, 1)]
|
|
1173
|
+
return _assemble(path, ROUTE.name, ROUTE.unit_kind, len(pages), units, tally,
|
|
1174
|
+
not_carried=ROUTE.not_carried, heads=heads, numbers=numbers)
|
|
1175
|
+
|
|
1176
|
+
|
|
1177
|
+
ROUTE = Route("pdf", "page", (".pdf",), _render_pdf,
|
|
1178
|
+
("images, figures and the text drawn inside them",
|
|
1179
|
+
"a table that spans a page break",
|
|
1180
|
+
"text rotated out of the horizontal reading order"))
|
|
1181
|
+
|
|
1182
|
+
|
|
1183
|
+
def render_pdf(path) -> tuple[str, dict]:
|
|
1184
|
+
"""(Markdown, tally) for a PDF; refuses a document with no text layer, by name."""
|
|
1185
|
+
return _render_pdf(Path(path))[:2]
|