@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.
Files changed (50) hide show
  1. package/LICENSE +5 -0
  2. package/README.md +75 -0
  3. package/bin/install.js +45 -0
  4. package/package.json +29 -0
  5. package/skills/architecture-walkthrough/SKILL.md +223 -0
  6. package/skills/architecture-walkthrough/references/sections.md +29 -0
  7. package/skills/architecture-walkthrough/scripts/check_structure.py +200 -0
  8. package/skills/autotest-webapp-ui/SKILL.md +58 -0
  9. package/skills/backend-code-review/SKILL.md +386 -0
  10. package/skills/backend-code-review/references/report-format.md +333 -0
  11. package/skills/backend-code-review/scripts/list_routes.py +269 -0
  12. package/skills/backend-code-review/scripts/sweep.py +550 -0
  13. package/skills/backend-code-review/scripts/verify_citations.py +201 -0
  14. package/skills/be-brief/SKILL.md +18 -0
  15. package/skills/clarke-list-excel/SKILL.md +51 -0
  16. package/skills/clarke-list-excel/references/output-schema.md +125 -0
  17. package/skills/clarke-list-excel/scripts/clarke_common.py +251 -0
  18. package/skills/clarke-list-excel/scripts/clarke_extract.py +487 -0
  19. package/skills/clarke-list-excel/scripts/load_clarke.py +322 -0
  20. package/skills/clarke-list-excel/scripts/run_all.py +63 -0
  21. package/skills/datalab-api/SKILL.md +163 -0
  22. package/skills/datalab-api/references/parameters-and-payload.md +121 -0
  23. package/skills/datalab-api/references/table-selection.md +35 -0
  24. package/skills/datalab-api/scripts/datalab_tables.py +365 -0
  25. package/skills/find-test-seam/SKILL.md +41 -0
  26. package/skills/frontend-code-review/SKILL.md +247 -0
  27. package/skills/frontend-code-review-2/SKILL.md +192 -0
  28. package/skills/frontend-code-review-2/scripts/fetch_pr_comments.py +65 -0
  29. package/skills/frontend-code-review-2/scripts/render_report.py +139 -0
  30. package/skills/murtaza-breif/SKILL.md +143 -0
  31. package/skills/murtaza-breif/scripts/save_brief.py +128 -0
  32. package/skills/pdf-to-json/SKILL.md +42 -0
  33. package/skills/pdf-to-json/references/output-schema.md +168 -0
  34. package/skills/pdf-to-json/scripts/extract_figures.py +319 -0
  35. package/skills/pdf-to-json/scripts/load_mongo.py +287 -0
  36. package/skills/pdf-to-json/scripts/pdf_extract.py +1313 -0
  37. package/skills/record-api-traffic/SKILL.md +434 -0
  38. package/skills/record-api-traffic/references/reading-recordings.md +224 -0
  39. package/skills/record-api-traffic/scripts/check-schema.mjs +184 -0
  40. package/skills/record-api-traffic/scripts/dump-quotation.mjs +67 -0
  41. package/skills/record-api-traffic/scripts/dump-source-excel.mjs +75 -0
  42. package/skills/record-api-traffic/scripts/lib/repo.mjs +109 -0
  43. package/skills/record-api-traffic/scripts/preflight.py +528 -0
  44. package/skills/record-api-traffic/scripts/record-api-traffic.py +720 -0
  45. package/skills/refac-wrt-business-goal/SKILL.md +305 -0
  46. package/skills/refac-wrt-business-goal/references/critic.md +170 -0
  47. package/skills/system-resource-triage/SKILL.md +180 -0
  48. package/skills/system-resource-triage/scripts/reap.sh +116 -0
  49. package/skills/system-resource-triage/scripts/triage.sh +111 -0
  50. 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"![]({urllib.parse.quote(path)})"
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"![{btype}]({urllib.parse.quote(src)})"] 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()