@sitar_fiercer4c/skills 0.1.0
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.
- package/LICENSE +5 -0
- package/README.md +75 -0
- package/bin/install.js +45 -0
- package/package.json +29 -0
- package/skills/architecture-walkthrough/SKILL.md +223 -0
- package/skills/architecture-walkthrough/references/sections.md +29 -0
- package/skills/architecture-walkthrough/scripts/check_structure.py +200 -0
- package/skills/autotest-webapp-ui/SKILL.md +58 -0
- package/skills/backend-code-review/SKILL.md +386 -0
- package/skills/backend-code-review/references/report-format.md +333 -0
- package/skills/backend-code-review/scripts/list_routes.py +269 -0
- package/skills/backend-code-review/scripts/sweep.py +550 -0
- package/skills/backend-code-review/scripts/verify_citations.py +201 -0
- package/skills/be-brief/SKILL.md +18 -0
- package/skills/clarke-list-excel/SKILL.md +51 -0
- package/skills/clarke-list-excel/references/output-schema.md +125 -0
- package/skills/clarke-list-excel/scripts/clarke_common.py +251 -0
- package/skills/clarke-list-excel/scripts/clarke_extract.py +487 -0
- package/skills/clarke-list-excel/scripts/load_clarke.py +322 -0
- package/skills/clarke-list-excel/scripts/run_all.py +63 -0
- package/skills/datalab-api/SKILL.md +163 -0
- package/skills/datalab-api/references/parameters-and-payload.md +121 -0
- package/skills/datalab-api/references/table-selection.md +35 -0
- package/skills/datalab-api/scripts/datalab_tables.py +365 -0
- package/skills/find-test-seam/SKILL.md +41 -0
- package/skills/frontend-code-review/SKILL.md +247 -0
- package/skills/frontend-code-review-2/SKILL.md +192 -0
- package/skills/frontend-code-review-2/scripts/fetch_pr_comments.py +65 -0
- package/skills/frontend-code-review-2/scripts/render_report.py +139 -0
- package/skills/murtaza-breif/SKILL.md +143 -0
- package/skills/murtaza-breif/scripts/save_brief.py +128 -0
- package/skills/pdf-to-json/SKILL.md +42 -0
- package/skills/pdf-to-json/references/output-schema.md +168 -0
- package/skills/pdf-to-json/scripts/extract_figures.py +319 -0
- package/skills/pdf-to-json/scripts/load_mongo.py +287 -0
- package/skills/pdf-to-json/scripts/pdf_extract.py +1313 -0
- package/skills/record-api-traffic/SKILL.md +434 -0
- package/skills/record-api-traffic/references/reading-recordings.md +224 -0
- package/skills/record-api-traffic/scripts/check-schema.mjs +184 -0
- package/skills/record-api-traffic/scripts/dump-quotation.mjs +67 -0
- package/skills/record-api-traffic/scripts/dump-source-excel.mjs +75 -0
- package/skills/record-api-traffic/scripts/lib/repo.mjs +109 -0
- package/skills/record-api-traffic/scripts/preflight.py +528 -0
- package/skills/record-api-traffic/scripts/record-api-traffic.py +720 -0
- package/skills/refac-wrt-business-goal/SKILL.md +305 -0
- package/skills/refac-wrt-business-goal/references/critic.md +170 -0
- package/skills/system-resource-triage/SKILL.md +180 -0
- package/skills/system-resource-triage/scripts/reap.sh +116 -0
- package/skills/system-resource-triage/scripts/triage.sh +111 -0
- package/skills/using-git-worktrees/SKILL.md +167 -0
|
@@ -0,0 +1,1313 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Extract a PDF into structured JSON plus a readable Markdown rendering.
|
|
3
|
+
|
|
4
|
+
pdf_extract.py <pdf-or-dir> [--out-dir DIR] [--mode fast|balanced|accurate]
|
|
5
|
+
|
|
6
|
+
Submits the PDF to the Datalab Convert API (datalab.to), polls until the
|
|
7
|
+
conversion completes, then writes two files per input:
|
|
8
|
+
|
|
9
|
+
<out-dir>/<stem>.json every block, every table, with page and bbox
|
|
10
|
+
<out-dir>/<stem>.md the same document rendered for a human to read
|
|
11
|
+
|
|
12
|
+
One API call produces both. `output_format=json` returns a block tree where
|
|
13
|
+
each block carries its own self-contained HTML, so the Markdown is rendered
|
|
14
|
+
locally rather than paid for a second time.
|
|
15
|
+
|
|
16
|
+
Needs DATALAB_API_KEY in the environment or in a .env / .env.local found by
|
|
17
|
+
walking up from the PDF's directory and from the working directory.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import base64
|
|
22
|
+
import binascii
|
|
23
|
+
import datetime as dt
|
|
24
|
+
import hashlib
|
|
25
|
+
import itertools
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import re
|
|
29
|
+
import sys
|
|
30
|
+
import time
|
|
31
|
+
import urllib.parse
|
|
32
|
+
from html.parser import HTMLParser
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
import requests
|
|
36
|
+
|
|
37
|
+
DEFAULT_BASE_URL = "https://www.datalab.to/api/v1"
|
|
38
|
+
DEFAULT_OUT_DIR = Path("/home/dev/workspaces/murtaza-workspaces")
|
|
39
|
+
MODES = ("fast", "balanced", "accurate")
|
|
40
|
+
POLL_SECONDS = 2
|
|
41
|
+
POLL_ATTEMPTS = 450 # 15 minutes; long scanned catalogues genuinely take that
|
|
42
|
+
|
|
43
|
+
TABLE_TYPES = ("Table", "TableOfContents")
|
|
44
|
+
IMAGE_TYPES = ("Figure", "Picture", "FigureGroup", "PictureGroup", "Diagram")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ----------------------------------------------------------------- config
|
|
48
|
+
|
|
49
|
+
def find_env_value(key: str, start_dirs) -> str | None:
|
|
50
|
+
"""Read `key` from the first .env.local / .env found walking upward.
|
|
51
|
+
|
|
52
|
+
The Backend/.env of this project is the canonical home of DATALAB_API_KEY,
|
|
53
|
+
so searching up from both the PDF and the working directory finds it
|
|
54
|
+
whether the script is run from the repo root, from Backend/, or from
|
|
55
|
+
wherever the PDF happens to live.
|
|
56
|
+
"""
|
|
57
|
+
seen = set()
|
|
58
|
+
for start in start_dirs:
|
|
59
|
+
for directory in [start, *start.parents]:
|
|
60
|
+
if directory in seen:
|
|
61
|
+
continue
|
|
62
|
+
seen.add(directory)
|
|
63
|
+
for name in (".env.local", ".env", "Backend/.env", "Backend/.env.local"):
|
|
64
|
+
env = directory / name
|
|
65
|
+
if not env.is_file():
|
|
66
|
+
continue
|
|
67
|
+
for line in env.read_text(errors="ignore").splitlines():
|
|
68
|
+
line = line.strip()
|
|
69
|
+
if line.startswith("#") or not line.startswith(f"{key}="):
|
|
70
|
+
continue
|
|
71
|
+
value = line.split("=", 1)[1].strip().strip("'\"")
|
|
72
|
+
if value:
|
|
73
|
+
return value
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def api_key(pdf: Path) -> str:
|
|
78
|
+
key = os.environ.get("DATALAB_API_KEY") or find_env_value(
|
|
79
|
+
"DATALAB_API_KEY", [pdf.resolve().parent, Path.cwd()])
|
|
80
|
+
if not key:
|
|
81
|
+
raise SystemExit(
|
|
82
|
+
"DATALAB_API_KEY not found. Set it in the environment, or in a .env "
|
|
83
|
+
"in any parent directory (this project keeps it in Backend/.env).")
|
|
84
|
+
return key
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def base_url(pdf: Path) -> str:
|
|
88
|
+
# datalab.to without `www` 302-redirects, and the redirect turns the POST
|
|
89
|
+
# into a GET and drops the multipart body, so the host matters.
|
|
90
|
+
return (os.environ.get("DATALAB_BASE_URL")
|
|
91
|
+
or find_env_value("DATALAB_BASE_URL", [pdf.resolve().parent, Path.cwd()])
|
|
92
|
+
or DEFAULT_BASE_URL).rstrip("/")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ------------------------------------------------------------ api access
|
|
96
|
+
|
|
97
|
+
def strip_images(node):
|
|
98
|
+
"""Drop base64 payloads from a response destined for the cache.
|
|
99
|
+
|
|
100
|
+
Images are stored twice — top-level `images` and again inside each figure
|
|
101
|
+
block — and on a scanned catalogue they are the overwhelming majority of
|
|
102
|
+
the bytes. Names are preserved either way, so nothing is lost except the
|
|
103
|
+
pixels themselves.
|
|
104
|
+
"""
|
|
105
|
+
if isinstance(node, list):
|
|
106
|
+
for item in node:
|
|
107
|
+
strip_images(item)
|
|
108
|
+
elif isinstance(node, dict):
|
|
109
|
+
if node.get("images"):
|
|
110
|
+
node["images"] = {name: "" for name in node["images"]}
|
|
111
|
+
for key in ("children", "json"):
|
|
112
|
+
if node.get(key):
|
|
113
|
+
strip_images(node[key])
|
|
114
|
+
return node
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def cache_path(pdf: Path, opts: dict, cache_dir: Path, keep_images: bool) -> Path:
|
|
118
|
+
"""Key the cache on the resolved path *and* every option that changes the body.
|
|
119
|
+
|
|
120
|
+
Two different PDFs named report.pdf would otherwise share an entry and
|
|
121
|
+
silently serve each other's blocks, and a re-run with --force-ocr would
|
|
122
|
+
return the non-OCR response and look like the flag does nothing. keep_images
|
|
123
|
+
belongs in the key for the same reason: an earlier run strips the base64
|
|
124
|
+
payloads before caching, so without it a later --keep-images run reads that
|
|
125
|
+
stripped entry and writes no images at all while reporting success.
|
|
126
|
+
"""
|
|
127
|
+
stamp = json.dumps([str(pdf.resolve()), opts, keep_images], sort_keys=True)
|
|
128
|
+
digest = hashlib.sha1(stamp.encode()).hexdigest()[:10]
|
|
129
|
+
images = ".img" if keep_images else ""
|
|
130
|
+
return cache_dir / f"{pdf.stem}.{opts['mode']}{images}.{digest}.json"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def convert(pdf: Path, key: str, url: str, opts: dict, cache_dir: Path,
|
|
134
|
+
refresh: bool, keep_images: bool) -> dict:
|
|
135
|
+
"""Submit one PDF and poll until Datalab finishes with it."""
|
|
136
|
+
cache = cache_path(pdf, opts, cache_dir, keep_images)
|
|
137
|
+
if cache.exists() and not refresh:
|
|
138
|
+
print(f" cached -> {cache}", file=sys.stderr)
|
|
139
|
+
return json.loads(cache.read_text())
|
|
140
|
+
|
|
141
|
+
data = {"output_format": "json", "mode": opts["mode"]}
|
|
142
|
+
if opts.get("page_range"):
|
|
143
|
+
data["page_range"] = opts["page_range"]
|
|
144
|
+
if opts.get("max_pages"):
|
|
145
|
+
data["max_pages"] = str(opts["max_pages"])
|
|
146
|
+
if opts.get("force_ocr"):
|
|
147
|
+
data["force_ocr"] = "true"
|
|
148
|
+
if opts.get("use_llm"):
|
|
149
|
+
data["use_llm"] = "true"
|
|
150
|
+
|
|
151
|
+
headers = {"X-API-Key": key}
|
|
152
|
+
with pdf.open("rb") as fh:
|
|
153
|
+
resp = requests.post(f"{url}/convert", headers=headers,
|
|
154
|
+
files={"file": (pdf.name, fh, "application/pdf")},
|
|
155
|
+
data=data, timeout=180)
|
|
156
|
+
if resp.status_code >= 400:
|
|
157
|
+
raise SystemExit(f"{pdf.name}: submit failed (HTTP {resp.status_code}): {resp.text[:400]}")
|
|
158
|
+
submitted = resp.json()
|
|
159
|
+
if not submitted.get("success"):
|
|
160
|
+
raise SystemExit(f"{pdf.name}: submit rejected: {submitted.get('error')}")
|
|
161
|
+
|
|
162
|
+
check_url = submitted["request_check_url"]
|
|
163
|
+
started = time.time()
|
|
164
|
+
for attempt in range(POLL_ATTEMPTS):
|
|
165
|
+
result = requests.get(check_url, headers=headers, timeout=60).json()
|
|
166
|
+
status = result.get("status")
|
|
167
|
+
if status == "complete":
|
|
168
|
+
result["request_id"] = submitted.get("request_id")
|
|
169
|
+
if not keep_images:
|
|
170
|
+
strip_images(result)
|
|
171
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
172
|
+
cache.write_text(json.dumps(result, indent=2))
|
|
173
|
+
print(f" converted in {time.time() - started:.1f}s -> cached {cache}", file=sys.stderr)
|
|
174
|
+
return result
|
|
175
|
+
if status == "failed" or result.get("error"):
|
|
176
|
+
raise SystemExit(f"{pdf.name}: conversion failed: {result.get('error')}")
|
|
177
|
+
if attempt and attempt % 15 == 0:
|
|
178
|
+
print(f" still {status}... {time.time() - started:.0f}s", file=sys.stderr)
|
|
179
|
+
time.sleep(POLL_SECONDS)
|
|
180
|
+
raise SystemExit(f"{pdf.name}: timed out after {POLL_ATTEMPTS * POLL_SECONDS}s")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# ---------------------------------------------------------- block walking
|
|
184
|
+
|
|
185
|
+
def walk_blocks(node, out=None):
|
|
186
|
+
"""Depth-first walk yielding every block in document order.
|
|
187
|
+
|
|
188
|
+
Blocks nest arbitrarily — a Table can sit inside a Group inside a Page —
|
|
189
|
+
so recursion is the only reliable traversal. Most leaf blocks come back
|
|
190
|
+
with `children: []` and their full content already inlined in `html`.
|
|
191
|
+
"""
|
|
192
|
+
if out is None:
|
|
193
|
+
out = []
|
|
194
|
+
if isinstance(node, list):
|
|
195
|
+
for item in node:
|
|
196
|
+
walk_blocks(item, out)
|
|
197
|
+
return out
|
|
198
|
+
if not isinstance(node, dict):
|
|
199
|
+
return out
|
|
200
|
+
if node.get("block_type"):
|
|
201
|
+
out.append(node)
|
|
202
|
+
walk_blocks(node.get("children") or [], out)
|
|
203
|
+
return out
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def page_number(block, fallback=None):
|
|
207
|
+
"""Page index as a 1-based number.
|
|
208
|
+
|
|
209
|
+
Blocks carry a 0-based `page` int; Page blocks sometimes only have the id
|
|
210
|
+
`/page/3/Page/3`. Prefer the field, fall back to parsing the id, so a
|
|
211
|
+
block never lands on an unknown page.
|
|
212
|
+
"""
|
|
213
|
+
value = block.get("page")
|
|
214
|
+
if isinstance(value, int):
|
|
215
|
+
return value + 1
|
|
216
|
+
match = re.match(r"/page/(\d+)", block.get("id") or "")
|
|
217
|
+
if match:
|
|
218
|
+
return int(match.group(1)) + 1
|
|
219
|
+
return fallback
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# ----------------------------------------------------------- html -> text
|
|
223
|
+
|
|
224
|
+
class TableParser(HTMLParser):
|
|
225
|
+
"""Flatten an HTML table into rows of cell strings.
|
|
226
|
+
|
|
227
|
+
Cells are placed by occupancy rather than appended, so colspan/rowspan
|
|
228
|
+
consume the columns they actually cover. PDF tables merge header cells
|
|
229
|
+
constantly, and appending would shift every later cell left — a header
|
|
230
|
+
spanning three columns followed by one more would put that second header
|
|
231
|
+
at column 1 and misalign it against every data row beneath.
|
|
232
|
+
"""
|
|
233
|
+
|
|
234
|
+
def __init__(self):
|
|
235
|
+
super().__init__()
|
|
236
|
+
self.cells, self.filled_rows = {}, set()
|
|
237
|
+
self.covered = {}
|
|
238
|
+
self.row_idx, self.col_idx = -1, 0
|
|
239
|
+
self.cell, self.span = None, (1, 1)
|
|
240
|
+
self.header_rows = set()
|
|
241
|
+
|
|
242
|
+
@staticmethod
|
|
243
|
+
def _span(attrs, name):
|
|
244
|
+
try:
|
|
245
|
+
return max(1, int(dict(attrs).get(name, 1)))
|
|
246
|
+
except (TypeError, ValueError):
|
|
247
|
+
return 1
|
|
248
|
+
|
|
249
|
+
def handle_starttag(self, tag, attrs):
|
|
250
|
+
if tag == "tr":
|
|
251
|
+
self.row_idx += 1
|
|
252
|
+
self.col_idx = 0
|
|
253
|
+
elif tag in ("td", "th"):
|
|
254
|
+
self.cell = []
|
|
255
|
+
self.span = (self._span(attrs, "colspan"), self._span(attrs, "rowspan"))
|
|
256
|
+
if tag == "th":
|
|
257
|
+
self.header_rows.add(self.row_idx)
|
|
258
|
+
elif tag == "br" and self.cell is not None:
|
|
259
|
+
self.cell.append(" ")
|
|
260
|
+
|
|
261
|
+
def handle_endtag(self, tag):
|
|
262
|
+
if tag not in ("td", "th") or self.cell is None:
|
|
263
|
+
return
|
|
264
|
+
text = re.sub(r"\s+", " ", "".join(self.cell)).strip()
|
|
265
|
+
self.cell = None
|
|
266
|
+
colspan, rowspan = self.span
|
|
267
|
+
while (self.row_idx, self.col_idx) in self.cells:
|
|
268
|
+
self.col_idx += 1
|
|
269
|
+
for dr in range(rowspan):
|
|
270
|
+
for dc in range(colspan):
|
|
271
|
+
if dr == dc == 0:
|
|
272
|
+
self.cells[(self.row_idx, self.col_idx)] = text
|
|
273
|
+
else:
|
|
274
|
+
# A span continuation is blank as text but is *not* an empty
|
|
275
|
+
# cell: the markup states this row is covered by `text`.
|
|
276
|
+
# Recording that lets a later forward-fill cite the document
|
|
277
|
+
# rather than guess.
|
|
278
|
+
self.cells[(self.row_idx + dr, self.col_idx + dc)] = ""
|
|
279
|
+
self.covered[(self.row_idx + dr, self.col_idx + dc)] = text
|
|
280
|
+
self.filled_rows.add(self.row_idx)
|
|
281
|
+
self.col_idx += colspan
|
|
282
|
+
|
|
283
|
+
def handle_data(self, data):
|
|
284
|
+
if self.cell is not None:
|
|
285
|
+
self.cell.append(data)
|
|
286
|
+
|
|
287
|
+
def _shape(self):
|
|
288
|
+
width = max(c for _, c in self.cells) + 1
|
|
289
|
+
rows = sorted({r for r, _ in self.cells} | self.filled_rows)
|
|
290
|
+
return width, rows
|
|
291
|
+
|
|
292
|
+
def grid(self):
|
|
293
|
+
if not self.cells:
|
|
294
|
+
return []
|
|
295
|
+
width, rows = self._shape()
|
|
296
|
+
return [[self.cells.get((r, c), "") for c in range(width)] for r in rows]
|
|
297
|
+
|
|
298
|
+
def span_grid(self):
|
|
299
|
+
"""Same shape as `grid()`, holding the value that covers each cell.
|
|
300
|
+
|
|
301
|
+
`None` where the cell owns its own content (or is genuinely empty);
|
|
302
|
+
a string where a rowspan/colspan from elsewhere reaches into it.
|
|
303
|
+
"""
|
|
304
|
+
if not self.cells:
|
|
305
|
+
return []
|
|
306
|
+
width, rows = self._shape()
|
|
307
|
+
return [[self.covered.get((r, c)) for c in range(width)] for r in rows]
|
|
308
|
+
|
|
309
|
+
def owned_grid(self):
|
|
310
|
+
"""Which positions the row wrote itself, as opposed to inheriting."""
|
|
311
|
+
if not self.cells:
|
|
312
|
+
return []
|
|
313
|
+
width, rows = self._shape()
|
|
314
|
+
return [[(r, c) in self.cells and (r, c) not in self.covered
|
|
315
|
+
for c in range(width)] for r in rows]
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def repair_short_rows(grid, spans, owned):
|
|
319
|
+
"""Re-place rows the parser had to left-justify because a span ran out.
|
|
320
|
+
|
|
321
|
+
A merged category cell whose `rowspan` Datalab counts one row short leaves
|
|
322
|
+
that row with no cell to inherit, so its own cells start at column 0 and
|
|
323
|
+
every field lands one place left — the SKU under Category, the price under
|
|
324
|
+
the wrong header. The row still renders fine, which is why it survives
|
|
325
|
+
review, and a single such row is enough to turn a grouping column into a
|
|
326
|
+
list of SKUs.
|
|
327
|
+
|
|
328
|
+
The rest of the block already shows where each kind of value belongs, so
|
|
329
|
+
the run of cells is slid to wherever its content fits the column profiles
|
|
330
|
+
best, and only when that beats leaving it alone by a clear margin.
|
|
331
|
+
"""
|
|
332
|
+
if not grid:
|
|
333
|
+
return grid, spans
|
|
334
|
+
width = len(grid[0])
|
|
335
|
+
lengths = [sum(row) for row in owned]
|
|
336
|
+
modal = max(set(lengths), key=lengths.count) if lengths else 0
|
|
337
|
+
typical = [grid[i] for i, n in enumerate(lengths) if n == modal]
|
|
338
|
+
if len(typical) < 3 or modal == width:
|
|
339
|
+
return grid, spans
|
|
340
|
+
profiles = [column_profile(typical, c) for c in range(width)]
|
|
341
|
+
|
|
342
|
+
for i, flags in enumerate(owned):
|
|
343
|
+
positions = [c for c, on in enumerate(flags) if on]
|
|
344
|
+
if not positions or len(positions) == width:
|
|
345
|
+
continue
|
|
346
|
+
if positions != list(range(positions[0], positions[-1] + 1)):
|
|
347
|
+
continue
|
|
348
|
+
# Candidates follow the parser's own rule — cells fill the free columns
|
|
349
|
+
# in order, stepping over whatever a span already covers — so the only
|
|
350
|
+
# question is which free column the run starts at.
|
|
351
|
+
free = [c for c in range(width) if spans[i][c] is None]
|
|
352
|
+
values = [grid[i][c] for c in positions]
|
|
353
|
+
if len(free) <= len(values):
|
|
354
|
+
continue
|
|
355
|
+
placements = [free[j:j + len(values)]
|
|
356
|
+
for j in range(len(free) - len(values) + 1)]
|
|
357
|
+
scored = sorted(
|
|
358
|
+
((sum(profile_similarity(profiles[c], {cell_kind(v): 1.0})
|
|
359
|
+
for c, v in zip(cols, values)) / len(values), cols)
|
|
360
|
+
for cols in placements), key=lambda s: s[0], reverse=True)
|
|
361
|
+
best, cols = scored[0]
|
|
362
|
+
if cols == positions or best - scored[1][0] < 0.15:
|
|
363
|
+
continue
|
|
364
|
+
row, span = [""] * width, [None] * width
|
|
365
|
+
for c in range(width):
|
|
366
|
+
if spans[i][c] is not None:
|
|
367
|
+
row[c], span[c] = grid[i][c], spans[i][c]
|
|
368
|
+
for c, value in zip(cols, values):
|
|
369
|
+
row[c] = value
|
|
370
|
+
grid[i], spans[i] = row, span
|
|
371
|
+
return grid, spans
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def to_grid(html: str):
|
|
375
|
+
"""Return (grid, has_header, spans).
|
|
376
|
+
|
|
377
|
+
`has_header` comes from whether Datalab used <th> in the first row, which
|
|
378
|
+
is the only trustworthy signal available. Assuming row 0 is always a header
|
|
379
|
+
quietly eats a real data row on every table that continues from the
|
|
380
|
+
previous page — those come back as pure <td> with no header at all.
|
|
381
|
+
|
|
382
|
+
`spans` is a parallel grid naming the merged cell that covers each blank,
|
|
383
|
+
which is what makes a category column recoverable rather than guessable.
|
|
384
|
+
"""
|
|
385
|
+
parser = TableParser()
|
|
386
|
+
parser.feed(html or "")
|
|
387
|
+
grid, spans = repair_short_rows(parser.grid(), parser.span_grid(),
|
|
388
|
+
parser.owned_grid())
|
|
389
|
+
return grid, 0 in parser.header_rows, spans
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
class TextParser(HTMLParser):
|
|
393
|
+
"""Plain text from a block's HTML, keeping list items on their own lines."""
|
|
394
|
+
|
|
395
|
+
def __init__(self):
|
|
396
|
+
super().__init__()
|
|
397
|
+
self.parts = []
|
|
398
|
+
|
|
399
|
+
def handle_starttag(self, tag, attrs):
|
|
400
|
+
if tag in ("li", "br", "p", "tr"):
|
|
401
|
+
self.parts.append("\n")
|
|
402
|
+
if tag == "img":
|
|
403
|
+
alt = dict(attrs).get("alt") or dict(attrs).get("src") or "image"
|
|
404
|
+
self.parts.append(f"[{alt}]")
|
|
405
|
+
if tag in ("td", "th"):
|
|
406
|
+
self.parts.append("\t")
|
|
407
|
+
|
|
408
|
+
def handle_endtag(self, tag):
|
|
409
|
+
if tag in ("li", "p", "tr", "div"):
|
|
410
|
+
self.parts.append("\n")
|
|
411
|
+
|
|
412
|
+
def handle_data(self, data):
|
|
413
|
+
self.parts.append(data)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def to_text(html: str) -> str:
|
|
417
|
+
parser = TextParser()
|
|
418
|
+
parser.feed(html or "")
|
|
419
|
+
text = "".join(parser.parts)
|
|
420
|
+
text = re.sub(r"[ \t]+", " ", text)
|
|
421
|
+
text = re.sub(r"\n\s*\n+", "\n", text)
|
|
422
|
+
return text.strip()
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
# ------------------------------------------------------------ html -> md
|
|
426
|
+
|
|
427
|
+
def md_escape(cell: str) -> str:
|
|
428
|
+
return cell.replace("|", "\\|").replace("\n", " ").strip()
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def md_image(path: str) -> str:
|
|
432
|
+
"""An image link that survives a stem with spaces in it.
|
|
433
|
+
|
|
434
|
+
PDF filenames routinely contain spaces, and the images directory is named
|
|
435
|
+
after the file, so an unencoded link stops at the first space and every
|
|
436
|
+
picture renders broken.
|
|
437
|
+
"""
|
|
438
|
+
return f"})"
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def grid_to_markdown(grid, has_header: bool, headers=None) -> list[str]:
|
|
442
|
+
"""Render a full table. Nothing is truncated — this file is the document.
|
|
443
|
+
|
|
444
|
+
Markdown tables require a header row, so a headerless fragment gets either
|
|
445
|
+
the headers carried over from the table it continues or `col_1..col_n`
|
|
446
|
+
placeholders. Promoting its first data row instead would hide a real row.
|
|
447
|
+
"""
|
|
448
|
+
if not grid:
|
|
449
|
+
return ["_empty table_"]
|
|
450
|
+
width = max(len(r) for r in grid)
|
|
451
|
+
padded = [r + [""] * (width - len(r)) for r in grid]
|
|
452
|
+
if has_header:
|
|
453
|
+
header, body = padded[0], padded[1:]
|
|
454
|
+
else:
|
|
455
|
+
header, body = list(headers or []), padded
|
|
456
|
+
header = (header + [""] * width)[:width]
|
|
457
|
+
header = [h or f"col_{i + 1}" for i, h in enumerate(header)]
|
|
458
|
+
lines = ["| " + " | ".join(md_escape(h) for h in header) + " |",
|
|
459
|
+
"| " + " | ".join("---" for _ in header) + " |"]
|
|
460
|
+
lines += ["| " + " | ".join(md_escape(c) for c in row) + " |" for row in body]
|
|
461
|
+
return lines
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def image_description(html: str) -> str:
|
|
465
|
+
"""Datalab's own description of a picture, which is generated text.
|
|
466
|
+
|
|
467
|
+
It arrives in a `<div class="img-description">` alongside the `<img>`. It is
|
|
468
|
+
often genuinely useful — for a flow diagram it can be a full transcription —
|
|
469
|
+
but it is the model's reading of the image, not text present in the PDF, so
|
|
470
|
+
it is kept apart from block text everywhere rather than blended in.
|
|
471
|
+
"""
|
|
472
|
+
return to_text(re.sub(r"<img[^>]*>", "", html or ""))
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def block_to_markdown(block, tables_by_id=None, image_dir="", figures_by_block=None) -> list[str]:
|
|
476
|
+
"""One block as Markdown, chosen by block type rather than by parsing HTML.
|
|
477
|
+
|
|
478
|
+
Datalab already told us what each block *is*; honouring that gives cleaner
|
|
479
|
+
Markdown than a generic html2md pass, and keeps headings as headings so the
|
|
480
|
+
output has a navigable structure.
|
|
481
|
+
"""
|
|
482
|
+
btype = block.get("block_type") or ""
|
|
483
|
+
html = block.get("html") or ""
|
|
484
|
+
|
|
485
|
+
if btype in TABLE_TYPES:
|
|
486
|
+
resolved = (tables_by_id or {}).get(block.get("id"))
|
|
487
|
+
if not resolved:
|
|
488
|
+
grid, has_header, _ = to_grid(html)
|
|
489
|
+
return grid_to_markdown(grid, has_header, None)
|
|
490
|
+
|
|
491
|
+
# Rendered from the aligned rows rather than the raw grid, so a
|
|
492
|
+
# fragment prints under the headers it actually belongs to. The merged
|
|
493
|
+
# category is repeated on every row it covers: the PDF draws it once
|
|
494
|
+
# down the side of the block, which does not survive flattening.
|
|
495
|
+
rows = [list(r) for r in resolved["rows_data"]]
|
|
496
|
+
group = resolved.get("group_column")
|
|
497
|
+
if group and group["index"] is not None and resolved.get("categories"):
|
|
498
|
+
for i, row in enumerate(rows):
|
|
499
|
+
if group["index"] < len(row):
|
|
500
|
+
row[group["index"]] = resolved["categories"][i]
|
|
501
|
+
headers = list(resolved.get("headers") or [])
|
|
502
|
+
photos = resolved.get("row_images")
|
|
503
|
+
if photos:
|
|
504
|
+
# The picture goes back into the column the PDF drew it in, so the
|
|
505
|
+
# row reads as one product. A merged photo covering several rows is
|
|
506
|
+
# repeated on each of them, for the same reason the category is.
|
|
507
|
+
column = resolved.get("image_column")
|
|
508
|
+
if column is None:
|
|
509
|
+
column = max(len(headers), max((len(r) for r in rows), default=0))
|
|
510
|
+
headers = (headers + [""] * (column + 1 - len(headers)))
|
|
511
|
+
headers[column] = headers[column] or "Image"
|
|
512
|
+
for i, row in enumerate(rows):
|
|
513
|
+
names = photos[i] if i < len(photos) else []
|
|
514
|
+
if not names:
|
|
515
|
+
continue
|
|
516
|
+
while len(row) <= column:
|
|
517
|
+
row.append("")
|
|
518
|
+
row[column] = " ".join(md_image(image_dir + n) for n in names)
|
|
519
|
+
lines = grid_to_markdown(rows, False, headers)
|
|
520
|
+
|
|
521
|
+
notes = []
|
|
522
|
+
source = resolved.get("header_source", "")
|
|
523
|
+
if source.startswith("carried"):
|
|
524
|
+
note = f"continues the table at `{source.split(':', 1)[1]}`; headers carried over"
|
|
525
|
+
if resolved.get("alignment"):
|
|
526
|
+
a = resolved["alignment"]
|
|
527
|
+
note += (f", realigned ({a['mode']} {a['left']}/{a['right']}, "
|
|
528
|
+
f"confidence {a['confidence']})")
|
|
529
|
+
notes.append(note)
|
|
530
|
+
elif source == "none":
|
|
531
|
+
notes.append("**no headers could be matched to this fragment**")
|
|
532
|
+
if resolved.get("dropped_header_rows"):
|
|
533
|
+
notes.append(f"{resolved['dropped_header_rows']} reprinted header row(s) removed")
|
|
534
|
+
inferred = sum(1 for s in resolved.get("category_fill", []) if s.startswith("carried"))
|
|
535
|
+
if inferred:
|
|
536
|
+
notes.append(f"category inferred for {inferred} of {len(rows)} rows")
|
|
537
|
+
if notes:
|
|
538
|
+
lines = ["_(" + "; ".join(notes) + ")_", ""] + lines
|
|
539
|
+
return lines
|
|
540
|
+
|
|
541
|
+
if btype in IMAGE_TYPES:
|
|
542
|
+
names = list((block.get("images") or {}).keys())
|
|
543
|
+
# Written with the directory the files actually land in. A bare filename
|
|
544
|
+
# renders as a broken image in every viewer, which reads as a failed
|
|
545
|
+
# extraction when the picture is sitting right there on disk.
|
|
546
|
+
rendered = (figures_by_block or {}).get(block.get("id"))
|
|
547
|
+
src = rendered or (f"{image_dir}{names[0]}" if names else None)
|
|
548
|
+
lines = [f"})"] if src else []
|
|
549
|
+
caption = image_description(html)
|
|
550
|
+
if caption:
|
|
551
|
+
# Labelled because this text is Datalab's reading of the picture, not
|
|
552
|
+
# anything written in the PDF. Unlabelled it reads as authorial prose
|
|
553
|
+
# and ends up quoted back as if the document said it.
|
|
554
|
+
lines += ["", "> **Datalab image description (generated, not PDF text):**",
|
|
555
|
+
"> " + caption.replace("\n", "\n> ")]
|
|
556
|
+
return lines
|
|
557
|
+
|
|
558
|
+
text = to_text(html)
|
|
559
|
+
if not text:
|
|
560
|
+
return []
|
|
561
|
+
|
|
562
|
+
if btype == "SectionHeader":
|
|
563
|
+
# Shift two levels down: the file title owns h1 and page markers own h2,
|
|
564
|
+
# so the document's own h1 has to start at h3 to keep the outline honest.
|
|
565
|
+
match = re.search(r"<h([1-6])", html)
|
|
566
|
+
level = min(6, int(match.group(1)) + 2) if match else 3
|
|
567
|
+
return ["#" * level + " " + text.replace("\n", " ")]
|
|
568
|
+
|
|
569
|
+
if btype in ("ListGroup", "ListItem"):
|
|
570
|
+
return [f"- {line.strip()}" for line in text.splitlines() if line.strip()]
|
|
571
|
+
|
|
572
|
+
if btype in ("Code", "Equation"):
|
|
573
|
+
return ["```", text, "```"]
|
|
574
|
+
|
|
575
|
+
return [text]
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
# ------------------------------------------------- aligning table fragments
|
|
579
|
+
|
|
580
|
+
def cell_kind(value: str) -> str:
|
|
581
|
+
"""Coarse type of one cell, used to recognise a column by what it holds.
|
|
582
|
+
|
|
583
|
+
Column *names* vanish when a table fragments across a page break, but the
|
|
584
|
+
shape of the values does not: a price column stays decimal, a SKU column
|
|
585
|
+
stays code-like. Comparing those shapes is what lets a headerless fragment
|
|
586
|
+
be matched to the right headers instead of merely the right column count.
|
|
587
|
+
"""
|
|
588
|
+
value = (value or "").strip()
|
|
589
|
+
if not value:
|
|
590
|
+
return "blank"
|
|
591
|
+
if value.lower().startswith("image:"):
|
|
592
|
+
return "image"
|
|
593
|
+
if re.fullmatch(r"\d+", value):
|
|
594
|
+
return "int"
|
|
595
|
+
if re.fullmatch(r"\d[\d,]*\.\d+", value):
|
|
596
|
+
return "decimal"
|
|
597
|
+
if re.fullmatch(r"[A-Za-z0-9][\w./\-]*", value) and any(c.isdigit() for c in value):
|
|
598
|
+
return "code"
|
|
599
|
+
return "text"
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def column_profile(rows, index: int) -> dict:
|
|
603
|
+
"""Fraction of each cell kind in one column."""
|
|
604
|
+
kinds = {}
|
|
605
|
+
for row in rows:
|
|
606
|
+
kind = cell_kind(row[index] if index < len(row) else "")
|
|
607
|
+
kinds[kind] = kinds.get(kind, 0) + 1
|
|
608
|
+
total = sum(kinds.values()) or 1
|
|
609
|
+
return {k: v / total for k, v in kinds.items()}
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def profile_similarity(a: dict, b: dict) -> float:
|
|
613
|
+
"""Histogram intersection: 1.0 when two columns hold the same mix of kinds."""
|
|
614
|
+
return sum(min(a.get(k, 0.0), b.get(k, 0.0)) for k in set(a) | set(b))
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def normalise_header(value: str) -> str:
|
|
618
|
+
return re.sub(r"[^a-z0-9]", "", (value or "").lower())
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def is_header_echo(row, header_sets) -> bool:
|
|
622
|
+
"""True when a <td> row is really the header line repeated mid-table.
|
|
623
|
+
|
|
624
|
+
Price lists reprint the header at the top of every page, and when the page
|
|
625
|
+
break lands inside one Datalab block that reprint arrives as ordinary data.
|
|
626
|
+
Left alone it becomes a product whose price is the string "List Price AED"
|
|
627
|
+
and — worse — whose category is the string "Category".
|
|
628
|
+
|
|
629
|
+
Every known header set is tried, not just the most recent one. A document
|
|
630
|
+
can run several table schemas at once, and a reprint of schema A landing in
|
|
631
|
+
a block that currently continues schema B is exactly the case that slips
|
|
632
|
+
through when only the latest headers are consulted.
|
|
633
|
+
"""
|
|
634
|
+
seen = [normalise_header(c) for c in row if (c or "").strip()]
|
|
635
|
+
if len(seen) < 3:
|
|
636
|
+
return False
|
|
637
|
+
for headers in header_sets:
|
|
638
|
+
wanted = {normalise_header(h) for h in headers if normalise_header(h)}
|
|
639
|
+
if len(wanted) < 3:
|
|
640
|
+
continue
|
|
641
|
+
if sum(1 for c in seen if c in wanted) >= 0.8 * len(seen):
|
|
642
|
+
return True
|
|
643
|
+
return False
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def all_blank(rows, index: int) -> bool:
|
|
647
|
+
return all(not (row[index] if index < len(row) else "").strip() for row in rows)
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def align_rows(rows, headers, reference_rows):
|
|
651
|
+
"""Fit rows of the wrong width onto `headers`, or refuse to.
|
|
652
|
+
|
|
653
|
+
A fragment loses whichever columns the page break cut off — on this
|
|
654
|
+
project's Stanley price list that is the *leading* merged Category column,
|
|
655
|
+
not the trailing image column, so a rule like "drop the last blank column"
|
|
656
|
+
would slide every field one place left and produce a table that looks
|
|
657
|
+
immaculate and is wrong in every row.
|
|
658
|
+
|
|
659
|
+
So the offset is chosen by evidence: try each way of lining the fragment up
|
|
660
|
+
against the headers, score it on how well each column's content matches the
|
|
661
|
+
same column in the table the headers came from, and take the winner only if
|
|
662
|
+
it is both good and unambiguous. Widening is lossless (missing columns come
|
|
663
|
+
back as blanks) and narrowing only ever drops columns that are entirely
|
|
664
|
+
empty, so no cell is discarded to make a fit.
|
|
665
|
+
|
|
666
|
+
Returns (aligned_rows, plan, score) or None when nothing fits. A plan is one
|
|
667
|
+
fragment column index — or None for "no column here" — per header.
|
|
668
|
+
"""
|
|
669
|
+
width = max((len(r) for r in rows), default=0)
|
|
670
|
+
rows = [(r + [""] * (width - len(r)))[:width] for r in rows]
|
|
671
|
+
target = len(headers)
|
|
672
|
+
if not rows or not target:
|
|
673
|
+
return None
|
|
674
|
+
|
|
675
|
+
# Datalab sometimes emits a spare empty column *inside* a fragment, not
|
|
676
|
+
# only at its ends, so a fit may need to drop a middle column and pad both
|
|
677
|
+
# sides at once. Only entirely blank columns are ever droppable, which is
|
|
678
|
+
# what keeps the transform lossless however the ends move.
|
|
679
|
+
blanks = [i for i in range(width) if all_blank(rows, i)]
|
|
680
|
+
candidates = []
|
|
681
|
+
least = max(0, width - target)
|
|
682
|
+
for k in range(least, min(len(blanks), least + 2) + 1):
|
|
683
|
+
for drop in itertools.combinations(blanks, k):
|
|
684
|
+
keep = [i for i in range(width) if i not in drop]
|
|
685
|
+
if len(keep) > target:
|
|
686
|
+
continue
|
|
687
|
+
for left in range(target - len(keep) + 1):
|
|
688
|
+
plan = [None] * target
|
|
689
|
+
for j, col in enumerate(keep):
|
|
690
|
+
plan[left + j] = col
|
|
691
|
+
candidates.append(tuple(plan))
|
|
692
|
+
candidates = list(dict.fromkeys(candidates))
|
|
693
|
+
if not candidates:
|
|
694
|
+
return None
|
|
695
|
+
|
|
696
|
+
# Every header is scored, including ones the plan leaves empty, so a fit
|
|
697
|
+
# that quietly abandons a column the reference fills is not rewarded for
|
|
698
|
+
# having fewer columns to be judged on.
|
|
699
|
+
empty = {"blank": 1.0}
|
|
700
|
+
refs = [column_profile(reference_rows, h) for h in range(target)]
|
|
701
|
+
frags = [column_profile(rows, c) for c in range(width)]
|
|
702
|
+
blank_set = set(blanks)
|
|
703
|
+
|
|
704
|
+
# Two plans that disagree only about which *empty* column sits where put
|
|
705
|
+
# every real value in the same place, so they are the same answer. Scoring
|
|
706
|
+
# them as rivals would make an unambiguous fit look contested and throw
|
|
707
|
+
# away a table that was never in doubt — so plans are grouped by where they
|
|
708
|
+
# send the columns that actually hold data, and only those groups compete.
|
|
709
|
+
grouped = {}
|
|
710
|
+
for plan in candidates:
|
|
711
|
+
score = sum(profile_similarity(refs[h], empty if c is None else frags[c])
|
|
712
|
+
for h, c in enumerate(plan)) / target
|
|
713
|
+
signature = tuple((h, c) for h, c in enumerate(plan)
|
|
714
|
+
if c is not None and c not in blank_set)
|
|
715
|
+
dropped = width - sum(1 for c in plan if c is not None)
|
|
716
|
+
current = grouped.get(signature)
|
|
717
|
+
if current is None or (score, -dropped) > (current[0], -current[2]):
|
|
718
|
+
grouped[signature] = (score, plan, dropped)
|
|
719
|
+
|
|
720
|
+
ranked = sorted(grouped.values(), key=lambda s: s[0], reverse=True)
|
|
721
|
+
best, plan, _ = ranked[0]
|
|
722
|
+
runner_up = ranked[1][0] if len(ranked) > 1 else 0.0
|
|
723
|
+
# A weak or contested fit is worse than no fit: misaligned rows read as
|
|
724
|
+
# valid data, whereas an empty `headers` list announces the problem.
|
|
725
|
+
if best < 0.55 or (len(ranked) > 1 and best - runner_up < 0.02):
|
|
726
|
+
return None
|
|
727
|
+
|
|
728
|
+
return apply_plan(rows, plan), plan, round(best, 3)
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def apply_plan(rows, plan, pad=""):
|
|
732
|
+
"""Reshape rows by an alignment plan: one source column per header slot."""
|
|
733
|
+
width = max((len(r) for r in rows), default=0)
|
|
734
|
+
rows = [(r + [pad] * (width - len(r)))[:width] for r in rows]
|
|
735
|
+
return [[pad if c is None else row[c] for c in plan] for row in rows]
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def describe_plan(plan, width):
|
|
739
|
+
"""The human-readable shape of an alignment plan."""
|
|
740
|
+
used = [c for c in plan if c is not None]
|
|
741
|
+
return {
|
|
742
|
+
"mode": "pad" if len(used) < len(plan) else "trim",
|
|
743
|
+
"left": plan.index(used[0]) if used else 0,
|
|
744
|
+
"right": len(plan) - 1 - plan.index(used[-1]) if used else 0,
|
|
745
|
+
"dropped_columns": [i for i in range(width) if i not in used],
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def is_identity(plan, width) -> bool:
|
|
750
|
+
return len(plan) == width and all(c == i for i, c in enumerate(plan))
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
# ------------------------------------------------------- category recovery
|
|
754
|
+
|
|
755
|
+
GROUP_HEADER_RE = re.compile(r"categor|group|section|family|range", re.I)
|
|
756
|
+
LABEL_TYPES = ("Text", "SectionHeader", "TextInlineMath", "Caption")
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def looks_like_group(rows, spans, index: int) -> bool:
|
|
760
|
+
"""Does this column behave like a grouping column, whatever it is called?
|
|
761
|
+
|
|
762
|
+
A column headed "Category" that holds a different SKU on every row is not a
|
|
763
|
+
category column — it is a misaligned table, and trusting the name turns one
|
|
764
|
+
bad alignment into hundreds of invented single-row categories. Grouping
|
|
765
|
+
values are words rather than codes, and there are far fewer of them than
|
|
766
|
+
there are rows. An entirely blank column still qualifies: that is the shape
|
|
767
|
+
of a merged cell Datalab lifted out into the margin.
|
|
768
|
+
"""
|
|
769
|
+
# Measured per row covered, not per distinct cell. A merged column prints
|
|
770
|
+
# one value for thirty rows, so counting cells gives a label the same
|
|
771
|
+
# weight as a single SKU that Datalab shifted a place left — and that one
|
|
772
|
+
# stray cell would then veto the grouping for the whole block. Spreading
|
|
773
|
+
# each value over the rows its span reaches puts the evidence in
|
|
774
|
+
# proportion.
|
|
775
|
+
values = []
|
|
776
|
+
for i, row in enumerate(rows):
|
|
777
|
+
own = (row[index] or "").strip() if index < len(row) else ""
|
|
778
|
+
cover = spans[i][index] if i < len(spans) and index < len(spans[i]) else None
|
|
779
|
+
value = own or (cover or "").strip()
|
|
780
|
+
if value:
|
|
781
|
+
values.append(value)
|
|
782
|
+
if not values:
|
|
783
|
+
return True
|
|
784
|
+
words = sum(1 for v in values if cell_kind(v) == "text")
|
|
785
|
+
if words < 0.8 * len(values):
|
|
786
|
+
return False
|
|
787
|
+
return len(set(values)) <= max(1, len(rows) // 3)
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def find_group_column(headers, rows, spans):
|
|
791
|
+
"""Which column carries the grouping label (the merged 'Category' column).
|
|
792
|
+
|
|
793
|
+
A matching header name is the first hint, but it is only accepted if the
|
|
794
|
+
column's contents agree. Otherwise the signal is rowspan: a column that
|
|
795
|
+
merges across many rows is a grouping column, whereas a price or a SKU
|
|
796
|
+
almost never merges. The image column also merges, so columns whose merged
|
|
797
|
+
values do not read as words are skipped.
|
|
798
|
+
"""
|
|
799
|
+
for i, header in enumerate(headers):
|
|
800
|
+
if header and GROUP_HEADER_RE.search(header) and looks_like_group(rows, spans, i):
|
|
801
|
+
return i
|
|
802
|
+
for i in range(len(headers)):
|
|
803
|
+
covers = [spans[r][i] for r in range(len(rows))
|
|
804
|
+
if i < len(spans[r]) and spans[r][i]]
|
|
805
|
+
if covers and all(cell_kind(c) == "text" for c in covers) \
|
|
806
|
+
and looks_like_group(rows, spans, i):
|
|
807
|
+
return i
|
|
808
|
+
return None
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def margin_labels_for(table_bbox, blocks):
|
|
812
|
+
"""Blocks sitting in the left margin beside a table, in document order.
|
|
813
|
+
|
|
814
|
+
When a table fragments, Datalab often lifts the merged category cell out of
|
|
815
|
+
the table entirely and returns it as a narrow, tall text block printed
|
|
816
|
+
sideways in the margin — "Blade and Knives" beside the knives rows. Those
|
|
817
|
+
blocks are the document *stating* the category for rows whose own cell is
|
|
818
|
+
gone, which is far better evidence than carrying a value down from
|
|
819
|
+
whatever happened to come before.
|
|
820
|
+
"""
|
|
821
|
+
if not table_bbox:
|
|
822
|
+
return []
|
|
823
|
+
tx0, ty0, tx1, ty1 = table_bbox
|
|
824
|
+
found = []
|
|
825
|
+
for block in blocks:
|
|
826
|
+
if block.get("block_type") not in LABEL_TYPES:
|
|
827
|
+
continue
|
|
828
|
+
bbox = block.get("bbox")
|
|
829
|
+
if not bbox:
|
|
830
|
+
continue
|
|
831
|
+
bx0, by0, bx1, by1 = bbox
|
|
832
|
+
# Left of the table body, narrow relative to it, and vertically
|
|
833
|
+
# alongside it — the shape of a rotated spanning cell.
|
|
834
|
+
if bx1 > tx0 + 8 or (bx1 - bx0) > 0.2 * max(1, tx1 - tx0):
|
|
835
|
+
continue
|
|
836
|
+
if by1 <= ty0 or by0 >= ty1:
|
|
837
|
+
continue
|
|
838
|
+
text = to_text(block.get("html")).strip()
|
|
839
|
+
if not text or len(text) > 60 or cell_kind(text) != "text":
|
|
840
|
+
continue
|
|
841
|
+
found.append({"text": text, "y0": by0, "y1": by1, "id": block.get("id")})
|
|
842
|
+
return sorted(found, key=lambda b: b["y0"])
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
# -------------------------------------------------------------- structure
|
|
846
|
+
|
|
847
|
+
def records_from_rows(headers, rows):
|
|
848
|
+
"""Header-keyed rows, but only when the headers can actually key them.
|
|
849
|
+
|
|
850
|
+
Blank or duplicated headers are common in PDF tables — merged spans, a unit
|
|
851
|
+
row beneath the header row, a spacer column. Building records anyway would
|
|
852
|
+
collapse two columns into one key and silently drop a field, so return None
|
|
853
|
+
and let the consumer read `rows_data` instead. An honest absence beats a
|
|
854
|
+
lossy dict that looks fine until someone trusts it.
|
|
855
|
+
"""
|
|
856
|
+
if not headers or not rows:
|
|
857
|
+
return None
|
|
858
|
+
# A blank trailing header is usually an unlabelled column (a product image,
|
|
859
|
+
# a notes column) — naming it keeps its values instead of discarding them.
|
|
860
|
+
# Duplicates are the genuinely ambiguous case, and there records are refused.
|
|
861
|
+
clean = [h.strip() or f"col_{i + 1}" for i, h in enumerate(headers)]
|
|
862
|
+
if len(set(clean)) != len(clean):
|
|
863
|
+
return None
|
|
864
|
+
width = len(clean)
|
|
865
|
+
return [dict(zip(clean, (row + [""] * width)[:width])) for row in rows]
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def build_table(block, num, references):
|
|
869
|
+
"""One Table block, lined up with the headers it belongs to.
|
|
870
|
+
|
|
871
|
+
`references` accumulates every table that carried its own <th> row, newest
|
|
872
|
+
first, so a fragment can be matched against the schema it actually
|
|
873
|
+
continues rather than merely the last one seen.
|
|
874
|
+
"""
|
|
875
|
+
grid, has_header, spans = to_grid(block.get("html"))
|
|
876
|
+
raw_width = len(grid[0]) if grid else 0
|
|
877
|
+
if has_header:
|
|
878
|
+
headers, body, body_spans = list(grid[0]), grid[1:], spans[1:]
|
|
879
|
+
else:
|
|
880
|
+
headers, body, body_spans = None, grid, spans
|
|
881
|
+
|
|
882
|
+
# A reprinted column-header line arrives as ordinary <td> when the page
|
|
883
|
+
# break falls inside one block. Left in place it becomes a product priced
|
|
884
|
+
# "List Price AED", so it is removed and counted rather than silently kept.
|
|
885
|
+
echo_against = ([headers] if headers else []) + [r["headers"] for r in references]
|
|
886
|
+
keep = [i for i, row in enumerate(body) if not is_header_echo(row, echo_against)]
|
|
887
|
+
echoes = len(body) - len(keep)
|
|
888
|
+
body = [body[i] for i in keep]
|
|
889
|
+
body_spans = [body_spans[i] for i in keep]
|
|
890
|
+
|
|
891
|
+
plan, score, source = None, None, "none"
|
|
892
|
+
if has_header:
|
|
893
|
+
source = "own"
|
|
894
|
+
headers = (headers + [""] * raw_width)[:raw_width]
|
|
895
|
+
rows = [(r + [""] * raw_width)[:raw_width] for r in body]
|
|
896
|
+
row_spans = [(s + [None] * raw_width)[:raw_width] for s in body_spans]
|
|
897
|
+
else:
|
|
898
|
+
rows, row_spans = body, body_spans
|
|
899
|
+
headers = []
|
|
900
|
+
# Every reference is scored on content, including the ones whose width
|
|
901
|
+
# already matches. Equal width is not evidence of equal meaning: a
|
|
902
|
+
# document running two schemas at once will happily offer an 8-column
|
|
903
|
+
# reference whose first header is "Category" to a fragment whose first
|
|
904
|
+
# column holds SKUs, and an unchecked width match writes those SKUs out
|
|
905
|
+
# as category names. Scoring costs nothing and catches it.
|
|
906
|
+
best = None
|
|
907
|
+
for ref in references:
|
|
908
|
+
fit = align_rows(body, ref["headers"], ref["rows"])
|
|
909
|
+
if fit and (best is None or fit[2] > best[0][2]):
|
|
910
|
+
best = (fit, ref)
|
|
911
|
+
if best:
|
|
912
|
+
(rows, plan, score), ref = best
|
|
913
|
+
row_spans = apply_plan(body_spans, plan, pad=None)
|
|
914
|
+
headers = list(ref["headers"])
|
|
915
|
+
# An identity plan means the fragment sat under the headers as-is;
|
|
916
|
+
# anything else moved columns and deserves the louder label.
|
|
917
|
+
if is_identity(plan, raw_width):
|
|
918
|
+
source, plan = f"carried:{ref['id']}", None
|
|
919
|
+
else:
|
|
920
|
+
source = f"carried-trimmed:{ref['id']}"
|
|
921
|
+
|
|
922
|
+
table = {
|
|
923
|
+
"id": block.get("id"),
|
|
924
|
+
"page": num,
|
|
925
|
+
"bbox": block.get("bbox"),
|
|
926
|
+
"columns": len(headers) if headers else raw_width,
|
|
927
|
+
"columns_raw": raw_width,
|
|
928
|
+
"row_count": len(rows),
|
|
929
|
+
"has_header": has_header,
|
|
930
|
+
"header_source": source,
|
|
931
|
+
"headers": headers,
|
|
932
|
+
"rows_data": rows,
|
|
933
|
+
"_spans": row_spans,
|
|
934
|
+
}
|
|
935
|
+
if plan:
|
|
936
|
+
table["alignment"] = dict(describe_plan(plan, raw_width), confidence=score)
|
|
937
|
+
if echoes:
|
|
938
|
+
table["dropped_header_rows"] = echoes
|
|
939
|
+
# A reference with no rows has no content profile, so nothing that carries
|
|
940
|
+
# its headers can ever be checked against it — it would hand its column
|
|
941
|
+
# names to any fragment of the same width, sight unseen.
|
|
942
|
+
if has_header and rows:
|
|
943
|
+
references.insert(0, {"headers": headers, "id": block.get("id"), "rows": rows})
|
|
944
|
+
return table
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
def assign_categories(tables, blocks_by_page):
|
|
948
|
+
"""Give every row a category, and say where each one came from.
|
|
949
|
+
|
|
950
|
+
Three sources, strongest first: the row's own cell; a rowspan that the
|
|
951
|
+
markup says covers it; a margin label the document prints beside it. Only
|
|
952
|
+
when none of those exist is a value carried from a neighbouring row — and
|
|
953
|
+
a carry is stopped at the point a margin label visibly ends, because past
|
|
954
|
+
that line the document has moved on to the next group even though the next
|
|
955
|
+
label may not be printed until the following page.
|
|
956
|
+
"""
|
|
957
|
+
for table in tables:
|
|
958
|
+
headers, rows, spans = table["headers"], table["rows_data"], table["_spans"]
|
|
959
|
+
index = find_group_column(headers, rows, spans) if headers else None
|
|
960
|
+
table["group_column"] = ({"index": index, "name": headers[index]}
|
|
961
|
+
if index is not None else None)
|
|
962
|
+
stated = []
|
|
963
|
+
for r, row in enumerate(rows):
|
|
964
|
+
own = (row[index] or "").strip() if index is not None and index < len(row) else ""
|
|
965
|
+
cover = (spans[r][index] or "").strip() if (
|
|
966
|
+
index is not None and r < len(spans) and index < len(spans[r])
|
|
967
|
+
and spans[r][index]) else ""
|
|
968
|
+
if own:
|
|
969
|
+
stated.append((own, "own"))
|
|
970
|
+
elif cover:
|
|
971
|
+
stated.append((cover, "rowspan"))
|
|
972
|
+
else:
|
|
973
|
+
stated.append(None)
|
|
974
|
+
table["_stated"] = stated
|
|
975
|
+
|
|
976
|
+
labels = ([] if any(stated) else
|
|
977
|
+
margin_labels_for(table["bbox"], blocks_by_page.get(table["page"], [])))
|
|
978
|
+
table["_labels"] = labels
|
|
979
|
+
if labels and rows:
|
|
980
|
+
if len(labels) == 1:
|
|
981
|
+
table["_stated"] = [(labels[0]["text"], "margin-label")] * len(rows)
|
|
982
|
+
else:
|
|
983
|
+
y0, y1 = table["bbox"][1], table["bbox"][3]
|
|
984
|
+
step = (y1 - y0) / len(rows)
|
|
985
|
+
for r in range(len(rows)):
|
|
986
|
+
mid = y0 + step * (r + 0.5)
|
|
987
|
+
near = min(labels, key=lambda l: abs((l["y0"] + l["y1"]) / 2 - mid))
|
|
988
|
+
table["_stated"][r] = (near["text"], "margin-label")
|
|
989
|
+
|
|
990
|
+
# Forward pass. `barrier` is the y at which the current margin label ran
|
|
991
|
+
# out; rows below it on the same page are a different group even though we
|
|
992
|
+
# cannot yet name it, so the carry is withheld rather than guessed.
|
|
993
|
+
live, live_page, barrier = None, None, None
|
|
994
|
+
for table in tables:
|
|
995
|
+
filled = []
|
|
996
|
+
for r, item in enumerate(table["_stated"]):
|
|
997
|
+
if item:
|
|
998
|
+
live, live_page, barrier = item[0], table["page"], None
|
|
999
|
+
if table["_labels"]:
|
|
1000
|
+
barrier = max(l["y1"] for l in table["_labels"])
|
|
1001
|
+
filled.append(item)
|
|
1002
|
+
continue
|
|
1003
|
+
dead = (barrier is not None and table["page"] == live_page
|
|
1004
|
+
and table["bbox"] and table["bbox"][1] >= barrier)
|
|
1005
|
+
filled.append((live, "carried-forward") if live and not dead else None)
|
|
1006
|
+
table["_filled"] = filled
|
|
1007
|
+
|
|
1008
|
+
# Backward pass for anything the forward pass refused to guess at.
|
|
1009
|
+
nxt = None
|
|
1010
|
+
for table in reversed(tables):
|
|
1011
|
+
for r in range(len(table["_filled"]) - 1, -1, -1):
|
|
1012
|
+
if table["_filled"][r]:
|
|
1013
|
+
nxt = table["_filled"][r][0]
|
|
1014
|
+
elif nxt:
|
|
1015
|
+
table["_filled"][r] = (nxt, "carried-back")
|
|
1016
|
+
|
|
1017
|
+
for table in tables:
|
|
1018
|
+
resolved = [f or ("", "none") for f in table["_filled"]]
|
|
1019
|
+
index = (table["group_column"] or {}).get("index")
|
|
1020
|
+
for key in ("_spans", "_stated", "_labels", "_filled"):
|
|
1021
|
+
table.pop(key, None)
|
|
1022
|
+
# A document with no grouping column gets no category keys at all,
|
|
1023
|
+
# rather than columns of empty strings that read like a failure.
|
|
1024
|
+
if not any(v for v, _ in resolved):
|
|
1025
|
+
continue
|
|
1026
|
+
table["categories"] = [v for v, _ in resolved]
|
|
1027
|
+
table["category_fill"] = [s for _, s in resolved]
|
|
1028
|
+
table["category_raw"] = [
|
|
1029
|
+
(row[index] or "").strip() if index is not None and index < len(row) else ""
|
|
1030
|
+
for row in table["rows_data"]]
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
def build_document(pdf: Path, result: dict, opts: dict, digest: str) -> dict:
|
|
1034
|
+
root = result.get("json") or {}
|
|
1035
|
+
blocks = walk_blocks(root)
|
|
1036
|
+
|
|
1037
|
+
pages, tables, figures = {}, [], []
|
|
1038
|
+
counts, references = {}, []
|
|
1039
|
+
for block in blocks:
|
|
1040
|
+
btype = block.get("block_type")
|
|
1041
|
+
counts[btype] = counts.get(btype, 0) + 1
|
|
1042
|
+
num = page_number(block)
|
|
1043
|
+
if btype == "Page":
|
|
1044
|
+
bbox = block.get("bbox") or [0, 0, 0, 0]
|
|
1045
|
+
pages.setdefault(num, {"page": num, "blocks": []})
|
|
1046
|
+
pages[num]["size"] = {"width": bbox[2], "height": bbox[3]}
|
|
1047
|
+
continue
|
|
1048
|
+
page = pages.setdefault(num, {"page": num, "blocks": []})
|
|
1049
|
+
|
|
1050
|
+
entry = {
|
|
1051
|
+
"id": block.get("id"),
|
|
1052
|
+
"type": btype,
|
|
1053
|
+
"bbox": block.get("bbox"),
|
|
1054
|
+
}
|
|
1055
|
+
if btype in TABLE_TYPES:
|
|
1056
|
+
table = build_table(block, num, references)
|
|
1057
|
+
entry["rows"] = table["row_count"]
|
|
1058
|
+
entry["columns"] = table["columns"]
|
|
1059
|
+
entry["has_header"] = table["has_header"]
|
|
1060
|
+
tables.append(table)
|
|
1061
|
+
elif btype in IMAGE_TYPES:
|
|
1062
|
+
names = list((block.get("images") or {}).keys())
|
|
1063
|
+
entry["images"] = names
|
|
1064
|
+
# Deliberately not `text`: this is Datalab describing the picture,
|
|
1065
|
+
# so it must not be counted or quoted as document content.
|
|
1066
|
+
entry["description"] = image_description(block.get("html"))
|
|
1067
|
+
entry["description_source"] = "datalab-generated"
|
|
1068
|
+
for name in names:
|
|
1069
|
+
figures.append({"name": name, "page": num, "block_id": block.get("id"),
|
|
1070
|
+
"bbox": block.get("bbox")})
|
|
1071
|
+
else:
|
|
1072
|
+
entry["text"] = to_text(block.get("html"))
|
|
1073
|
+
page["blocks"].append(entry)
|
|
1074
|
+
|
|
1075
|
+
blocks_by_page = {}
|
|
1076
|
+
for block in blocks:
|
|
1077
|
+
if block.get("block_type") != "Page":
|
|
1078
|
+
blocks_by_page.setdefault(page_number(block), []).append(block)
|
|
1079
|
+
assign_categories(tables, blocks_by_page)
|
|
1080
|
+
|
|
1081
|
+
for table in tables:
|
|
1082
|
+
records = records_from_rows(table["headers"], table["rows_data"])
|
|
1083
|
+
if records is not None:
|
|
1084
|
+
for i, record in enumerate(records if "categories" in table else []):
|
|
1085
|
+
# `category` is a fixed key so downstream code need not know
|
|
1086
|
+
# what this document calls the column; `category_raw` keeps
|
|
1087
|
+
# what was actually printed in the cell, and `category_fill`
|
|
1088
|
+
# says which of the two you are looking at.
|
|
1089
|
+
record["category"] = table["categories"][i]
|
|
1090
|
+
record["category_raw"] = table["category_raw"][i]
|
|
1091
|
+
record["category_fill"] = table["category_fill"][i]
|
|
1092
|
+
group = table.get("group_column")
|
|
1093
|
+
if group:
|
|
1094
|
+
record[group["name"] or f"col_{group['index'] + 1}"] = \
|
|
1095
|
+
table["categories"][i]
|
|
1096
|
+
table["records"] = records
|
|
1097
|
+
|
|
1098
|
+
ordered = [pages[k] for k in sorted(pages)]
|
|
1099
|
+
text_chars = sum(len(b.get("text") or "") for p in ordered for b in p["blocks"])
|
|
1100
|
+
meta = result.get("metadata") or {}
|
|
1101
|
+
|
|
1102
|
+
fills, groups = {}, {}
|
|
1103
|
+
grouped = any(t.get("group_column") for t in tables)
|
|
1104
|
+
for table in tables:
|
|
1105
|
+
for i, source in enumerate(table.get("category_fill") or []):
|
|
1106
|
+
fills[source] = fills.get(source, 0) + 1
|
|
1107
|
+
groups[table["categories"][i]] = groups.get(table["categories"][i], 0) + 1
|
|
1108
|
+
total_rows = sum(t["row_count"] for t in tables)
|
|
1109
|
+
header_sources = {}
|
|
1110
|
+
for table in tables:
|
|
1111
|
+
kind = table["header_source"].split(":")[0]
|
|
1112
|
+
header_sources[kind] = header_sources.get(kind, 0) + 1
|
|
1113
|
+
|
|
1114
|
+
return {
|
|
1115
|
+
"source": {
|
|
1116
|
+
"file": pdf.name,
|
|
1117
|
+
"path": str(pdf.resolve()),
|
|
1118
|
+
"bytes": pdf.stat().st_size,
|
|
1119
|
+
"sha256": digest,
|
|
1120
|
+
"extracted_at": dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds"),
|
|
1121
|
+
},
|
|
1122
|
+
"extraction": {
|
|
1123
|
+
"provider": "datalab.to/api/v1/convert",
|
|
1124
|
+
"output_format": "json",
|
|
1125
|
+
"mode": opts["mode"],
|
|
1126
|
+
"page_range": opts.get("page_range"),
|
|
1127
|
+
"max_pages": opts.get("max_pages"),
|
|
1128
|
+
"force_ocr": bool(opts.get("force_ocr")),
|
|
1129
|
+
"use_llm": bool(opts.get("use_llm")),
|
|
1130
|
+
"request_id": result.get("request_id"),
|
|
1131
|
+
"runtime_seconds": result.get("runtime"),
|
|
1132
|
+
"cost_cents": (result.get("cost_breakdown") or {}).get("final_cost_cents"),
|
|
1133
|
+
"page_count": result.get("page_count"),
|
|
1134
|
+
"failed_pages": meta.get("failed_pages") or [],
|
|
1135
|
+
},
|
|
1136
|
+
"summary": {
|
|
1137
|
+
"pages": len(ordered),
|
|
1138
|
+
"blocks": sum(len(p["blocks"]) for p in ordered),
|
|
1139
|
+
"tables": len(tables),
|
|
1140
|
+
"figures": len(figures),
|
|
1141
|
+
"text_characters": text_chars,
|
|
1142
|
+
"block_types": dict(sorted(counts.items())),
|
|
1143
|
+
"table_rows": total_rows,
|
|
1144
|
+
"header_sources": dict(sorted(header_sources.items())),
|
|
1145
|
+
# How each category was arrived at. "own"/"rowspan"/"margin-label"
|
|
1146
|
+
# are stated by the document; the "carried-*" counts are this
|
|
1147
|
+
# script's inference and are the number to be sceptical about.
|
|
1148
|
+
# Both keys are absent when no table has a grouping column, so an
|
|
1149
|
+
# empty category never looks like a failed extraction.
|
|
1150
|
+
**({"category_fill": dict(sorted(fills.items())),
|
|
1151
|
+
"categories": dict(sorted(groups.items(), key=lambda kv: -kv[1]))}
|
|
1152
|
+
if grouped else {}),
|
|
1153
|
+
},
|
|
1154
|
+
"pages": ordered,
|
|
1155
|
+
"tables": tables,
|
|
1156
|
+
"figures": figures,
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
|
|
1160
|
+
def render_markdown(doc: dict, result: dict, image_dir="") -> str:
|
|
1161
|
+
src, ex, summary = doc["source"], doc["extraction"], doc["summary"]
|
|
1162
|
+
lines = [
|
|
1163
|
+
f"# {src['file']}",
|
|
1164
|
+
"",
|
|
1165
|
+
f"Extracted {src['extracted_at']} via Datalab (`mode={ex['mode']}`"
|
|
1166
|
+
+ (f", `page_range={ex['page_range']}`" if ex["page_range"] else "")
|
|
1167
|
+
+ (", `force_ocr`" if ex["force_ocr"] else "")
|
|
1168
|
+
+ (", `use_llm`" if ex["use_llm"] else "") + ").",
|
|
1169
|
+
"",
|
|
1170
|
+
f"{summary['pages']} page(s) · {summary['blocks']} blocks · "
|
|
1171
|
+
f"{summary['tables']} table(s) · {summary['figures']} figure(s)",
|
|
1172
|
+
]
|
|
1173
|
+
if ex["failed_pages"]:
|
|
1174
|
+
lines += ["", f"**Pages Datalab could not parse: {ex['failed_pages']}**"]
|
|
1175
|
+
lines.append("")
|
|
1176
|
+
|
|
1177
|
+
tables_by_id = {t["id"]: t for t in doc["tables"]}
|
|
1178
|
+
figures_by_block = {f["block_id"]: f["file"] for f in doc.get("figures") or []
|
|
1179
|
+
if f.get("block_id") and f.get("file")}
|
|
1180
|
+
root = result.get("json") or {}
|
|
1181
|
+
by_page = {}
|
|
1182
|
+
for block in walk_blocks(root):
|
|
1183
|
+
if block.get("block_type") == "Page":
|
|
1184
|
+
continue
|
|
1185
|
+
by_page.setdefault(page_number(block), []).append(block)
|
|
1186
|
+
|
|
1187
|
+
for num in sorted(by_page):
|
|
1188
|
+
lines += [f"## Page {num}", ""]
|
|
1189
|
+
for block in by_page[num]:
|
|
1190
|
+
rendered = block_to_markdown(block, tables_by_id, image_dir, figures_by_block)
|
|
1191
|
+
if rendered:
|
|
1192
|
+
lines += rendered + [""]
|
|
1193
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
1194
|
+
|
|
1195
|
+
|
|
1196
|
+
def write_images(result: dict, out_dir: Path, stem: str) -> int:
|
|
1197
|
+
"""Write base64 images out as real files next to the JSON.
|
|
1198
|
+
|
|
1199
|
+
Kept out of the JSON deliberately: a base64 blob makes the file unreadable
|
|
1200
|
+
and unusable by anything that wants to grep it, and figures are far more
|
|
1201
|
+
useful as files you can open.
|
|
1202
|
+
"""
|
|
1203
|
+
images = result.get("images") or {}
|
|
1204
|
+
written = 0
|
|
1205
|
+
target = out_dir / f"{stem}_images"
|
|
1206
|
+
for name, payload in images.items():
|
|
1207
|
+
if not payload:
|
|
1208
|
+
continue
|
|
1209
|
+
try:
|
|
1210
|
+
data = base64.b64decode(payload)
|
|
1211
|
+
except (binascii.Error, ValueError):
|
|
1212
|
+
continue
|
|
1213
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
1214
|
+
(target / name).write_bytes(data)
|
|
1215
|
+
written += 1
|
|
1216
|
+
if written:
|
|
1217
|
+
print(f" images -> {target} ({written})", file=sys.stderr)
|
|
1218
|
+
return written
|
|
1219
|
+
|
|
1220
|
+
|
|
1221
|
+
# ------------------------------------------------------------------- main
|
|
1222
|
+
|
|
1223
|
+
def sha256_of(path: Path) -> str:
|
|
1224
|
+
digest = hashlib.sha256()
|
|
1225
|
+
with path.open("rb") as fh:
|
|
1226
|
+
for chunk in iter(lambda: fh.read(1 << 20), b""):
|
|
1227
|
+
digest.update(chunk)
|
|
1228
|
+
return digest.hexdigest()
|
|
1229
|
+
|
|
1230
|
+
|
|
1231
|
+
def collect_inputs(source: Path) -> list[Path]:
|
|
1232
|
+
if source.is_dir():
|
|
1233
|
+
return sorted(p for p in source.iterdir() if p.suffix.lower() == ".pdf")
|
|
1234
|
+
return [source]
|
|
1235
|
+
|
|
1236
|
+
|
|
1237
|
+
def main():
|
|
1238
|
+
parser = argparse.ArgumentParser(
|
|
1239
|
+
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
1240
|
+
parser.add_argument("input", type=Path, help="a PDF file or a directory of PDFs")
|
|
1241
|
+
parser.add_argument("--out-dir", type=Path, default=DEFAULT_OUT_DIR,
|
|
1242
|
+
help=f"where the .json and .md land (default: {DEFAULT_OUT_DIR})")
|
|
1243
|
+
parser.add_argument("--mode", choices=MODES, default="balanced",
|
|
1244
|
+
help="Datalab quality mode (default: balanced)")
|
|
1245
|
+
parser.add_argument("--pages", dest="page_range",
|
|
1246
|
+
help="0-indexed page selection, e.g. 0-4 or 0,2,7-9")
|
|
1247
|
+
parser.add_argument("--max-pages", type=int, help="stop after N pages")
|
|
1248
|
+
parser.add_argument("--force-ocr", action="store_true",
|
|
1249
|
+
help="re-OCR every page; use when the text layer is garbled")
|
|
1250
|
+
parser.add_argument("--use-llm", action="store_true",
|
|
1251
|
+
help="LLM pass for messy tables and merged cells (slower, costs more)")
|
|
1252
|
+
parser.add_argument("--page-images", action="store_true",
|
|
1253
|
+
help="crop every image out of the rendered PDF and attach "
|
|
1254
|
+
"it to the table rows it sits beside")
|
|
1255
|
+
parser.add_argument("--dpi", type=int, default=200,
|
|
1256
|
+
help="render resolution for --page-images (default 200)")
|
|
1257
|
+
parser.add_argument("--keep-images", action="store_true",
|
|
1258
|
+
help="also write figures as image files beside the JSON")
|
|
1259
|
+
parser.add_argument("--cache-dir", type=Path, default=Path("datalab_raw"),
|
|
1260
|
+
help="where raw API responses are cached (default: ./datalab_raw)")
|
|
1261
|
+
parser.add_argument("--refresh", action="store_true",
|
|
1262
|
+
help="ignore the cache and call the API again")
|
|
1263
|
+
args = parser.parse_args()
|
|
1264
|
+
|
|
1265
|
+
files = collect_inputs(args.input)
|
|
1266
|
+
if not files:
|
|
1267
|
+
parser.error(f"no PDF found at {args.input}")
|
|
1268
|
+
missing = [p for p in files if not p.is_file()]
|
|
1269
|
+
if missing:
|
|
1270
|
+
parser.error(f"not a file: {missing[0]}")
|
|
1271
|
+
|
|
1272
|
+
opts = {"mode": args.mode, "page_range": args.page_range, "max_pages": args.max_pages,
|
|
1273
|
+
"force_ocr": args.force_ocr, "use_llm": args.use_llm}
|
|
1274
|
+
|
|
1275
|
+
key = api_key(files[0])
|
|
1276
|
+
url = base_url(files[0])
|
|
1277
|
+
args.out_dir.mkdir(parents=True, exist_ok=True)
|
|
1278
|
+
|
|
1279
|
+
for pdf in files:
|
|
1280
|
+
print(f"{pdf.name} ({pdf.stat().st_size / 1e6:.1f} MB)", file=sys.stderr)
|
|
1281
|
+
result = convert(pdf, key, url, opts, args.cache_dir, args.refresh, args.keep_images)
|
|
1282
|
+
doc = build_document(pdf, result, opts, sha256_of(pdf))
|
|
1283
|
+
|
|
1284
|
+
photos = None
|
|
1285
|
+
if args.page_images:
|
|
1286
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
1287
|
+
from extract_figures import attach_figures
|
|
1288
|
+
photos = attach_figures(doc, pdf, args.out_dir, pdf.stem, args.dpi)
|
|
1289
|
+
|
|
1290
|
+
json_path = args.out_dir / f"{pdf.stem}.json"
|
|
1291
|
+
md_path = args.out_dir / f"{pdf.stem}.md"
|
|
1292
|
+
json_path.write_text(json.dumps(doc, indent=2, ensure_ascii=False) + "\n")
|
|
1293
|
+
md_path.write_text(render_markdown(doc, result, f"{pdf.stem}_images/"))
|
|
1294
|
+
if args.keep_images:
|
|
1295
|
+
write_images(result, args.out_dir, pdf.stem)
|
|
1296
|
+
if photos:
|
|
1297
|
+
print(f" images -> {args.out_dir / (pdf.stem + '_images')} "
|
|
1298
|
+
f"({photos['extracted']} at {photos['dpi']}dpi; "
|
|
1299
|
+
f"{photos['rows_with_image']} of "
|
|
1300
|
+
f"{photos['rows_with_image'] + photos['rows_without_image']} rows"
|
|
1301
|
+
f", {photos['unplaced_images']} outside any row)", file=sys.stderr)
|
|
1302
|
+
|
|
1303
|
+
s, e = doc["summary"], doc["extraction"]
|
|
1304
|
+
cost = "" if e["cost_cents"] is None else f", cost {e['cost_cents']}c"
|
|
1305
|
+
print(f" {s['pages']} pages, {s['blocks']} blocks, {s['tables']} tables{cost}",
|
|
1306
|
+
file=sys.stderr)
|
|
1307
|
+
if e["failed_pages"]:
|
|
1308
|
+
print(f" WARNING failed pages: {e['failed_pages']}", file=sys.stderr)
|
|
1309
|
+
print(f" json -> {json_path}\n md -> {md_path}", file=sys.stderr)
|
|
1310
|
+
|
|
1311
|
+
|
|
1312
|
+
if __name__ == "__main__":
|
|
1313
|
+
main()
|