bakhil-aissa commited on
Commit
eca742d
·
verified ·
1 Parent(s): 7bb4439

Delete table_extraction.py

Browse files
Files changed (1) hide show
  1. table_extraction.py +0 -565
table_extraction.py DELETED
@@ -1,565 +0,0 @@
1
- """
2
- table_extraction.py
3
- ====================
4
-
5
- Right-hand branch of the diagram: when a matched region's class is "table",
6
- its cropped image goes through TableFormerONNX -> OTSL string -> markdown.
7
-
8
- `TableFormerONNX`, `ocr_anchor_cells`, `seq_to_otsl`, and `table_image_to_otsl`
9
- are carried over from docling_.ipynb essentially unchanged (decoupled from
10
- that notebook's specific OCR-backend import style; backends now come from
11
- ocr_backends.py). `otsl_to_markdown` is new — it's the "otsl -> markdown" step
12
- in the diagram, which in the original notebook was done ad hoc in later
13
- cells (build a <doctag>, run DocTagsDocument/DoclingDocument). Folding it
14
- into one function makes it reusable per-table inside the full-page pipeline.
15
- It returns `(markdown, dataframe)` — the dataframe is built from
16
- `TableItem.export_to_dataframe()` with `collapse_spanned_columns` applied,
17
- since a raw rectangular DataFrame otherwise duplicates a colspan'd cell's
18
- value into every column it visually spans (see that function's docstring
19
- for why, and why `pd.read_html(export_to_html())` has the identical issue).
20
- """
21
-
22
- from __future__ import annotations
23
-
24
- import json
25
- import logging
26
- import time
27
- from pathlib import Path
28
- from typing import Optional, Union
29
- from urllib.parse import urlparse
30
-
31
- import cv2
32
- import numpy as np
33
- import onnxruntime as ort
34
- import pandas as pd
35
- from PIL import Image
36
-
37
- from .ocr_backends import OCRBackend
38
-
39
- logger = logging.getLogger(__name__)
40
-
41
- ANCHOR_TAGS = {"fcel", "ecel", "ched", "rhed", "srow"} # tags that occupy their own grid slot
42
- EMPTY_OTSL_PLACEHOLDER = "?" # inserted when a non-empty-tagged cell OCRs to nothing,
43
- # so the docling_core parser never sees an empty content cell
44
-
45
- MEAN = np.array([0.94247851, 0.94254675, 0.94292611], dtype=np.float32)
46
- STD = np.array([0.17910956, 0.17940403, 0.17931663], dtype=np.float32)
47
- IMAGE_SIZE = 448
48
-
49
-
50
- # --------------------------------------------------------------------------- #
51
- # bbox merge helpers (lcel chains)
52
- # --------------------------------------------------------------------------- #
53
- def merge_bboxes(box1: np.ndarray, box2: np.ndarray) -> np.ndarray:
54
- """Union the cxcywh boxes of an lcel chain's stub anchor and the real cell that closes it."""
55
- new_w = (box2[0] + box2[2] / 2) - (box1[0] - box1[2] / 2)
56
- new_h = (box2[1] + box2[3] / 2) - (box1[1] - box1[3] / 2)
57
- new_left = box1[0] - box1[2] / 2
58
- new_top = min((box2[1] - box2[3] / 2), (box1[1] - box1[3] / 2))
59
- return np.array(
60
- [new_left + new_w / 2, new_top + new_h / 2, new_w, new_h], dtype=np.float32
61
- )
62
-
63
-
64
- def apply_bbox_merge(coords: np.ndarray, bboxes_to_merge: dict) -> np.ndarray:
65
- coords = [np.asarray(c, dtype=np.float32) for c in coords]
66
- merged, skip = [], set()
67
- for i, box1 in enumerate(coords):
68
- if i in bboxes_to_merge:
69
- j = bboxes_to_merge[i]
70
- if j >= 0:
71
- skip.add(j)
72
- merged.append(merge_bboxes(box1, coords[j]))
73
- elif i not in skip:
74
- merged.append(box1)
75
- return np.stack(merged) if merged else np.empty((0, 4), dtype=np.float32)
76
-
77
-
78
- def make_session(path: str, threads: int = 2) -> ort.InferenceSession:
79
- opts = ort.SessionOptions()
80
- opts.inter_op_num_threads = 2
81
- opts.intra_op_num_threads = 4
82
- opts.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
83
- opts.enable_mem_pattern = True
84
- opts.enable_mem_reuse = True
85
- opts.enable_profiling = False
86
- return ort.InferenceSession(path, sess_options=opts, providers=["CPUExecutionProvider"])
87
-
88
-
89
- # --------------------------------------------------------------------------- #
90
- # TableFormer ONNX runner
91
- # --------------------------------------------------------------------------- #
92
- class TableFormerONNX:
93
- """
94
- encoder + decoder-step (autoregressive loop, KV-cache) + bbox_decoder,
95
- all ONNX Runtime. Produces a structure sequence (OTSL tags) plus a
96
- normalized cxcywh bbox per anchor cell.
97
-
98
- Usage:
99
- runner = TableFormerONNX(artifact_root="tableformerv1", variant="accurate")
100
- """
101
-
102
- def __init__(self, artifact_root: str = "tableformerv1", variant: str = "accurate", threads: int = 2):
103
- root = Path(artifact_root)
104
- onnx_dir = root / "onnx" / variant
105
- self.enc = make_session(str(onnx_dir / f"tableformer_{variant}_encoder.onnx"), threads)
106
- self.dec = make_session(str(onnx_dir / f"tableformer_{variant}_decoder_step.onnx"), threads)
107
- self.bbox = make_session(str(onnx_dir / f"tableformer_{variant}_bbox_decoder.onnx"), threads)
108
-
109
- with open(root / "tm_config.json", encoding="utf-8") as f:
110
- self.tag_map = json.load(f)["dataset_wordmap"]["word_map_tag"]
111
- self.rev_tag = {v: k for k, v in self.tag_map.items()}
112
-
113
- cache_input = next(i for i in self.dec.get_inputs() if i.name == "cache")
114
- self.num_layers = int(cache_input.shape[0])
115
- self.embed_dim = int(cache_input.shape[3])
116
-
117
- @staticmethod
118
- def is_valid_url(url_string: str) -> bool:
119
- try:
120
- result = urlparse(url_string)
121
- return all([result.scheme in ("http", "https"), result.netloc])
122
- except ValueError:
123
- return False
124
-
125
- def image_preprocess(self, img) -> np.ndarray:
126
- """Accepts a path, URL, PIL.Image, or HWC RGB ndarray."""
127
- if isinstance(img, str) and self.is_valid_url(img):
128
- import requests
129
-
130
- img = Image.open(requests.get(img, stream=True, timeout=30).raw)
131
- if isinstance(img, (str, Path)):
132
- img = Image.open(img)
133
- if isinstance(img, Image.Image):
134
- img = np.array(img.convert("RGB"))
135
- elif isinstance(img, np.ndarray) and img.ndim == 3 and img.shape[2] == 3:
136
- pass
137
- else:
138
- raise TypeError(f"Unsupported image type: {type(img)}")
139
-
140
- img = (img.astype(np.float32) - 255.0 * MEAN) / STD
141
- img = cv2.resize(img, (IMAGE_SIZE, IMAGE_SIZE), interpolation=cv2.INTER_CUBIC)
142
- img = img.transpose(2, 1, 0) / 255.0
143
- return np.expand_dims(img.astype(np.float32), axis=0)
144
-
145
- def draw_bbox(self, img_path: str, bboxs: np.ndarray, out_path: str = "draw_p.png") -> None:
146
- """Debug helper — draws predicted cxcywh boxes on the ORIGINAL-resolution image."""
147
- from PIL import ImageDraw
148
-
149
- pil_image = Image.open(img_path)
150
- width, height = pil_image.size
151
- draw = ImageDraw.Draw(pil_image)
152
- for cx, cy, w, h in np.asarray(bboxs):
153
- xyxy = (
154
- int((cx - 0.5 * w) * width), int((cy - 0.5 * h) * height),
155
- int((cx + 0.5 * w) * width), int((cy + 0.5 * h) * height),
156
- )
157
- draw.rectangle(xyxy, outline="red", width=2)
158
- pil_image.save(out_path)
159
-
160
- def predict(self, img: np.ndarray, max_steps: int = 1024) -> dict:
161
- """img: float32 [1, 3, 448, 448] (preprocessed). Returns seq / outputs_class /
162
- outputs_coord (cxcywh, normalized [0,1]) / bboxes_to_merge / timings."""
163
- t0 = time.time()
164
- enc_out, memory = self.enc.run(None, {"image": img})
165
- t_enc = time.time() - t0
166
-
167
- wm = self.tag_map
168
- decoded_tags = np.array([[wm["<start>"]]], dtype=np.int64)
169
- cache = np.zeros((self.num_layers, 0, 1, self.embed_dim), dtype=np.float32)
170
- skip_next_tag = True
171
- prev_tag_ucel = False
172
- line_num = 0
173
- first_lcel = True
174
- bbox_ind = 0
175
- cur_bbox_ind = -1
176
- bboxes_to_merge: dict = {}
177
- tag_H_buf = []
178
-
179
- t1 = time.time()
180
- for _ in range(max_steps):
181
- logits, last_hidden, cache = self.dec.run(
182
- None, {"decoded_tags": decoded_tags, "memory": memory, "cache": cache}
183
- )
184
- new_tag = int(np.argmax(logits, axis=1)[0])
185
-
186
- if line_num == 0 and new_tag == wm["xcel"]:
187
- new_tag = wm["lcel"]
188
- if prev_tag_ucel and new_tag == wm["lcel"]:
189
- new_tag = wm["fcel"]
190
-
191
- if new_tag == wm["<end>"]:
192
- decoded_tags = np.concatenate(
193
- [decoded_tags, np.array([[new_tag]], dtype=np.int64)], axis=0
194
- )
195
- break
196
-
197
- if not skip_next_tag:
198
- if new_tag in (wm["fcel"], wm["ecel"], wm["ched"], wm["rhed"],
199
- wm["srow"], wm["nl"], wm["ucel"]):
200
- tag_H_buf.append(last_hidden[:, 0, :].copy())
201
- if first_lcel is not True:
202
- bboxes_to_merge[cur_bbox_ind] = bbox_ind
203
- bbox_ind += 1
204
-
205
- if new_tag != wm["lcel"]:
206
- first_lcel = True
207
- else:
208
- if first_lcel:
209
- tag_H_buf.append(last_hidden[:, 0, :].copy())
210
- first_lcel = False
211
- cur_bbox_ind = bbox_ind
212
- bboxes_to_merge[cur_bbox_ind] = -1
213
- bbox_ind += 1
214
-
215
- skip_next_tag = new_tag in (wm["nl"], wm["ucel"], wm["xcel"])
216
- prev_tag_ucel = new_tag == wm["ucel"]
217
- if new_tag == wm["nl"]:
218
- line_num += 1
219
-
220
- decoded_tags = np.concatenate(
221
- [decoded_tags, np.array([[new_tag]], dtype=np.int64)], axis=0
222
- )
223
- t_dec = time.time() - t1
224
-
225
- seq = decoded_tags.squeeze().tolist()
226
-
227
- if tag_H_buf:
228
- tag_H_stacked = np.stack(
229
- [h[None, ...] if h.ndim == 1 else h for h in tag_H_buf], axis=0
230
- ).reshape(-1, 1, self.embed_dim).astype(np.float32)
231
- t2 = time.time()
232
- cls_logits, coord = self.bbox.run(None, {"enc_out": enc_out, "tag_H_stacked": tag_H_stacked})
233
- t_bbox = time.time() - t2
234
- coord = apply_bbox_merge(coord, bboxes_to_merge)
235
- else:
236
- cls_logits = np.empty((0, 3), dtype=np.float32)
237
- coord = np.empty((0, 4), dtype=np.float32)
238
- t_bbox = 0.0
239
-
240
- return {
241
- "seq": seq,
242
- "outputs_class": cls_logits,
243
- "outputs_coord": coord,
244
- "bboxes_to_merge": bboxes_to_merge,
245
- "timings": {"encoder": t_enc, "decoder": t_dec, "bbox": t_bbox,
246
- "total": t_enc + t_dec + t_bbox},
247
- }
248
-
249
-
250
- # --------------------------------------------------------------------------- #
251
- # OTSL assembly: structure tokens + per-cell OCR -> docling-ready OTSL string
252
- # --------------------------------------------------------------------------- #
253
- def compute_ioa(ocr_bbox: list[float], cell_bbox: list[float]) -> float:
254
- """Intersection over OCR-Token Area (IoA): maps a token to a cell if most
255
- of the token sits inside it."""
256
- ox1, oy1, ox2, oy2 = ocr_bbox
257
- cx1, cy1, cx2, cy2 = cell_bbox
258
-
259
- ix1 = max(ox1, cx1)
260
- iy1 = max(oy1, cy1)
261
- ix2 = min(ox2, cx2)
262
- iy2 = min(oy2, cy2)
263
-
264
- if ix2 <= ix1 or iy2 <= iy1:
265
- return 0.0
266
-
267
- inter_area = (ix2 - ix1) * (iy2 - iy1)
268
- ocr_area = (ox2 - ox1) * (oy2 - oy1)
269
- return inter_area / max(ocr_area, 1e-6)
270
-
271
-
272
- def ocr_anchor_cells(
273
- orig_image_path: str,
274
- seq: list,
275
- box_o: np.ndarray,
276
- rev_tag: dict,
277
- backend: Optional[OCRBackend] = None,
278
- tokens: Optional[list[dict]] = None,
279
- pad: int = 4,
280
- narrow_width: float = 0.08,
281
- ) -> list:
282
- """Maps text tokens into TableFormer cell boundaries via spatial intersection.
283
-
284
- Either `backend` (run OCR on the table crop) or pre-extracted `tokens`
285
- (e.g. pdfplumber words already expressed in the crop's local pixel space —
286
- see pipeline.py) must be supplied. `tokens` takes priority: when a PDF
287
- has a real text layer, reusing those exact words is both cheaper and more
288
- accurate than re-OCRing a table crop pdfplumber already read correctly.
289
- """
290
- if tokens is None and backend is None:
291
- raise ValueError("ocr_anchor_cells needs either `tokens` or `backend`")
292
-
293
- orig = Image.open(orig_image_path).convert("RGB")
294
- orig_w, orig_h = orig.size
295
-
296
- anchor_tags = [rev_tag[t] for t in seq[1:-1] if rev_tag[t] in ANCHOR_TAGS]
297
- assert len(anchor_tags) == box_o.shape[0], (
298
- f"{len(anchor_tags)} anchor tags vs {box_o.shape[0]} boxes — structural alignment issue."
299
- )
300
-
301
- ocr_tokens = tokens if tokens is not None else backend.get_text_boxes(orig)
302
- source = "pdfplumber tokens" if tokens is not None else type(backend).__name__
303
- logger.debug(
304
- "ocr_anchor_cells: %d anchor cells, %d tokens from %s",
305
- len(anchor_tags),
306
- len(ocr_tokens),
307
- source,
308
- )
309
- texts = []
310
-
311
- for i, (tag, (cx, cy, w, h)) in enumerate(zip(anchor_tags, box_o)):
312
- if tag == "ecel":
313
- texts.append("")
314
- continue
315
-
316
- x1 = max(0, (cx - w / 2) * orig_w - pad)
317
- y1 = max(0, (cy - h / 2) * orig_h - pad)
318
- x2 = min(orig_w, (cx + w / 2) * orig_w + pad)
319
- y2 = min(orig_h, (cy + h / 2) * orig_h + pad)
320
- cell_bbox = [x1, y1, x2, y2]
321
-
322
- matched_tokens = [t for t in ocr_tokens if compute_ioa(t["bbox"], cell_bbox) >= 0.45]
323
-
324
- if not matched_tokens:
325
- texts.append("")
326
- logger.debug(" cell[%d] tag=%s (empty)", i, tag)
327
- continue
328
-
329
- matched_tokens.sort(key=lambda t: (int(t["bbox"][1] / 6), t["bbox"][0]))
330
- cell_string = " ".join(t["text"] for t in matched_tokens)
331
- texts.append(cell_string.strip())
332
- logger.debug(" cell[%d] tag=%s text=%r", i, tag, cell_string.strip()[:60])
333
-
334
- return texts
335
-
336
-
337
- def _otsl_cell_text(tag: str, text: str) -> str | None:
338
- if tag == "ecel":
339
- return None
340
- cleaned = (text or "").strip()
341
- return cleaned if cleaned else EMPTY_OTSL_PLACEHOLDER
342
-
343
-
344
- def seq_to_otsl(seq: list, rev_tag: dict, texts: list) -> str:
345
- """Re-walk seq, inserting OCR text right after each anchor tag."""
346
- out, ti = [], 0
347
- for tok in seq[1:-1]:
348
- name = rev_tag[tok]
349
- if name == "nl":
350
- out.append("<nl>")
351
- continue
352
- out.append(f"<{name}>")
353
- if name in ANCHOR_TAGS:
354
- cell_text = _otsl_cell_text(name, texts[ti])
355
- if cell_text is not None:
356
- out.append(cell_text)
357
- ti += 1
358
- return "".join(out)
359
-
360
-
361
- def table_image_to_otsl(
362
- runner: TableFormerONNX,
363
- orig_image_path: str,
364
- backend: Optional[OCRBackend] = None,
365
- tokens: Optional[list[dict]] = None,
366
- ) -> str:
367
- """End-to-end: image -> ONNX TableFormer -> anchors filled from `tokens`
368
- (preferred, e.g. pdfplumber words) or OCR'd via `backend` -> OTSL string."""
369
- logger.info("TableFormer: processing %s", orig_image_path)
370
- preproc = runner.image_preprocess(orig_image_path)
371
- out = runner.predict(preproc)
372
- logger.debug(
373
- "TableFormer: seq length=%d, anchor boxes=%d",
374
- len(out["seq"]),
375
- out["outputs_coord"].shape[0],
376
- )
377
- texts = ocr_anchor_cells(
378
- orig_image_path, out["seq"], out["outputs_coord"], runner.rev_tag,
379
- backend=backend, tokens=tokens,
380
- )
381
- otsl = seq_to_otsl(out["seq"], runner.rev_tag, texts)
382
- logger.debug("TableFormer OTSL preview: %s", otsl[:200])
383
- return otsl
384
-
385
-
386
- def collapse_spanned_columns(df: "pd.DataFrame") -> "pd.DataFrame":
387
- """
388
- Collapse adjacent columns that are exact duplicates (same header text,
389
- identical value in every row) back into a single column.
390
-
391
- A plain rectangular DataFrame has no native way to represent a cell that
392
- spans multiple columns (e.g. an OTSL `lcel` chain / HTML `colspan`).
393
- Both `TableItem.export_to_dataframe()` and the `export_to_html()` +
394
- `pd.read_html()` round-trip handle this the same way: they repeat the
395
- spanned cell's value into every column it covers, so a 2-column-wide
396
- "Description" header ends up as two separate "Description" columns
397
- with identical content. This collapses those back into one.
398
-
399
- Note: this is a heuristic — if two genuinely distinct adjacent columns
400
- happen to share both the same header text and identical values in
401
- every row of this specific table, they'll also get collapsed. That's
402
- expected to be rare in practice (real tables don't usually have two
403
- different columns with the same name and identical data), but worth
404
- knowing if a table doesn't collapse cleanly.
405
- """
406
- cols = list(df.columns)
407
- if len(cols) <= 1:
408
- return df
409
-
410
- keep = [0]
411
- for i in range(1, len(cols)):
412
- prev = keep[-1]
413
- # pandas suffixes repeated header names with ".1", ".2", ... on
414
- # read_html; export_to_dataframe doesn't, so strip defensively.
415
- prev_header = str(cols[prev]).split(".")[0]
416
- cur_header = str(cols[i]).split(".")[0]
417
- same_header = prev_header == cur_header
418
- same_values = df.iloc[:, prev].astype(str).equals(df.iloc[:, i].astype(str))
419
- if same_header and same_values:
420
- continue # duplicate of the column we're keeping — drop it
421
- keep.append(i)
422
-
423
- out = df.iloc[:, keep].copy()
424
- out.columns = [str(c).split(".")[0] for c in out.columns]
425
- return out
426
-
427
-
428
- def _parse_md_table_row(line: str) -> list[str]:
429
- """Split one markdown table row into its cell strings, stripping whitespace."""
430
- return [c.strip() for c in line.strip().strip("|").split("|")]
431
-
432
-
433
- def _is_separator_row(cells: list[str]) -> bool:
434
- """Return True if every cell is a markdown alignment/separator marker (e.g. `---`, `:---:`)."""
435
- return all(set(c.replace(":", "").replace("-", "")) == set() and len(c) > 0 for c in cells)
436
-
437
-
438
- def collapse_markdown_table(md: str) -> str:
439
- """
440
- Apply the same duplicate-adjacent-column collapse to every markdown table
441
- inside `md`, in-place. This is necessary because markdown has no colspan
442
- syntax — docling_core's `export_to_markdown()` represents a colspan-N cell
443
- by repeating its value in N consecutive columns, which looks like
444
- `| Description | Description | Total |` for a 2-column-wide "Description"
445
- header. This function detects and removes those duplicate columns from the
446
- markdown string so the rendered table matches what the source document
447
- actually meant.
448
-
449
- The collapse criterion is identical to `collapse_spanned_columns`: two
450
- adjacent columns are merged when they share the same header text (after
451
- stripping pandas `.1`/`.2` dedup suffixes, though docling markdown doesn't
452
- add those) AND every data row has the same value in both columns.
453
-
454
- Non-table lines (headings, paragraphs, etc.) are passed through unchanged.
455
- """
456
- lines = md.splitlines()
457
- out_lines: list[str] = []
458
- i = 0
459
-
460
- while i < len(lines):
461
- line = lines[i]
462
-
463
- # Detect the start of a markdown table: a line containing at least one |
464
- # that isn't just punctuation.
465
- if "|" not in line:
466
- out_lines.append(line)
467
- i += 1
468
- continue
469
-
470
- # Collect the full table block (contiguous lines containing |).
471
- table_block: list[str] = []
472
- while i < len(lines) and "|" in lines[i]:
473
- table_block.append(lines[i])
474
- i += 1
475
-
476
- if len(table_block) < 2:
477
- # Not a real table (need at least header + separator).
478
- out_lines.extend(table_block)
479
- continue
480
-
481
- # Parse into header, optional separator, and data rows.
482
- header_cells = _parse_md_table_row(table_block[0])
483
- sep_idx = 1 if _is_separator_row(_parse_md_table_row(table_block[1])) else None
484
-
485
- data_rows: list[list[str]] = []
486
- for row_line in table_block[(2 if sep_idx is not None else 1):]:
487
- data_rows.append(_parse_md_table_row(row_line))
488
-
489
- if not data_rows:
490
- out_lines.extend(table_block)
491
- continue
492
-
493
- # Pad short rows so indexing is safe.
494
- n_cols = len(header_cells)
495
- data_rows = [r + [""] * max(0, n_cols - len(r)) for r in data_rows]
496
-
497
- # Determine which columns to keep — same logic as collapse_spanned_columns.
498
- keep: list[int] = [0]
499
- for j in range(1, n_cols):
500
- prev = keep[-1]
501
- prev_header = header_cells[prev].split(".")[0]
502
- cur_header = header_cells[j].split(".")[0]
503
- same_header = prev_header == cur_header
504
- same_values = all(
505
- (row[prev] if prev < len(row) else "") == (row[j] if j < len(row) else "")
506
- for row in data_rows
507
- )
508
- if same_header and same_values:
509
- continue
510
- keep.append(j)
511
-
512
- if len(keep) == n_cols:
513
- # Nothing to collapse — pass through unchanged.
514
- out_lines.extend(table_block)
515
- continue
516
-
517
- # Rebuild the table with only the kept columns.
518
- def _fmt_row(cells: list[str], indices: list[int]) -> str:
519
- return "| " + " | ".join(cells[k] for k in indices) + " |"
520
-
521
- def _fmt_sep(indices: list[int]) -> str:
522
- return "| " + " | ".join("---" for _ in indices) + " |"
523
-
524
- out_lines.append(_fmt_row(header_cells, keep))
525
- if sep_idx is not None:
526
- out_lines.append(_fmt_sep(keep))
527
- for row in data_rows:
528
- out_lines.append(_fmt_row(row, keep))
529
-
530
- return "\n".join(out_lines)
531
-
532
-
533
- def otsl_to_markdown(
534
- otsl: str, dummy_image_size: tuple[int, int] = (512, 512)
535
- ) -> tuple[str, "pd.DataFrame"]:
536
- """
537
- Wrap a raw OTSL tag string as <doctag><otsl>...</otsl></doctag>, load it
538
- through docling_core's DocTagsDocument/DoclingDocument, and export both
539
- markdown and a DataFrame for the (first) detected table.
540
-
541
- Returns `(markdown, dataframe)` — both have duplicate columns from
542
- colspan cells collapsed:
543
- - markdown: via `collapse_markdown_table` (markdown has no colspan syntax
544
- so docling repeats the spanned cell's text into every column it covers)
545
- - dataframe: via `TableItem.export_to_dataframe()` + `collapse_spanned_columns`
546
- (same duplication issue at the DataFrame level, same fix)
547
- """
548
- from docling_core.types.doc.document import DocTagsDocument, DoclingDocument
549
-
550
- otsl_doc = f"<doctag><otsl>{otsl}</otsl></doctag>"
551
- dummy_image = Image.new("RGB", dummy_image_size, color="black")
552
- doctags_doc = DocTagsDocument.from_doctags_and_image_pairs([otsl_doc], [dummy_image])
553
- doc = DoclingDocument.load_from_doctags(doctags_doc)
554
-
555
- md = collapse_markdown_table(doc.export_to_markdown())
556
-
557
- if doc.tables:
558
- df = doc.tables[0].export_to_dataframe(doc=doc)
559
- df = collapse_spanned_columns(df)
560
- logger.info("OTSL -> markdown: %d rows x %d cols", df.shape[0], df.shape[1])
561
- else:
562
- df = pd.DataFrame()
563
- logger.warning("OTSL -> markdown: no tables detected in OTSL output")
564
-
565
- return md, df