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/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]