hi-res-ocr / tabularize.py
imperiusrex's picture
HI-RES OCR: two-tab demo (handwritten + multilingual)
9ab0bf5 verified
Raw
History Blame Contribute Delete
8.29 kB
"""Heuristic table rendering from token geometry (no ML, pure Python).
Given the recognized tokens per reading-order line — each token carrying its
horizontal extent (x0, x1) — find the contiguous blocks of lines that form a
column-aligned table and render those as Markdown tables. Everything else
(headings, prose, notes) passes through as plain text, so a page that mixes a
table with ordinary text renders both correctly.
Approach (block-local, so prose elsewhere on the page cannot hide the table):
1. mark each row "structured" if it has an internal gap wider than a column
threshold (a table row),
2. group consecutive structured rows into runs,
3. for each run, find the column separators from that run only, and render it
as a table when it has >= 2 columns and >= 2 rows.
The column threshold is keyed to text *height*, not width: an inter-column gap
is roughly a line-height or more, while an inter-word space is ~0.3x of it.
Width is a poor yardstick because a cell may hold a wide phrase.
token_lines: list (in reading order) of rows; each row is a list of
``(text, x0, x1)`` tuples. ``line_height`` is the median text height in px.
"""
from __future__ import annotations
from statistics import median
GAP_FACTOR_W = 1.5 # fallback gap threshold (x median token width) if no height
GAP_FACTOR_H = 0.7 # gap threshold as a fraction of median text height
MIN_ROWS = 2
MIN_COLS = 2
def _md_line(s: str) -> str:
s = s.replace("|", "\\|")
if s.lstrip().startswith("#"):
s = s.replace("#", "\\#", 1)
return s
def _md_text(s: str) -> str:
"""Plain text -> Markdown: keep line breaks, avoid accidental formatting."""
return " \n".join(_md_line(line) for line in s.split("\n"))
def _has_wide_gap(row, min_gap) -> bool:
return any(b[1] - a[2] >= min_gap for a, b in zip(row, row[1:]))
def _column_separators(run, min_gap):
"""x-positions of whitespace channels that survive across the run's rows."""
intervals = sorted((x0, x1) for r in run for (_, x0, x1) in r)
if not intervals:
return []
merged = [list(intervals[0])]
for x0, x1 in intervals[1:]:
if x0 <= merged[-1][1]:
merged[-1][1] = max(merged[-1][1], x1)
else:
merged.append([x0, x1])
return [(a[1] + b[0]) / 2.0 for a, b in zip(merged, merged[1:])
if b[0] - a[1] >= min_gap]
def _col_of(x, seps):
j = 0
for s in seps:
if x > s:
j += 1
else:
break
return j
def _row_text(row) -> str:
return " ".join(t for (t, _, _) in row if t).strip()
def _grid_to_md(grid: list[list[str]]) -> str:
ncol = max(len(r) for r in grid)
grid = [r + [""] * (ncol - len(r)) for r in grid]
head = "| " + " | ".join(_md_line(c).strip() for c in grid[0]) + " |"
rule = "| " + " | ".join("---" for _ in range(ncol)) + " |"
body = ["| " + " | ".join(_md_line(c).strip() for c in r) + " |"
for r in grid[1:]]
return "\n".join([head, rule, *body])
def _build_grid(run, seps):
ncol = len(seps) + 1
grid = []
for r in run:
cells = [""] * ncol
for (t, x0, x1) in r:
k = _col_of((x0 + x1) / 2, seps)
cells[k] = (cells[k] + " " + t).strip()
grid.append(cells)
return grid
def _grid_md(grid) -> str:
ncol = len(grid[0])
keep = [c for c in range(ncol) if any(g[c] for g in grid)]
return _grid_to_md([[g[c] for c in keep] for g in grid])
def layout_to_markdown(token_lines, plain_text, *, line_height=None,
min_rows=MIN_ROWS, min_cols=MIN_COLS):
"""Render token_lines as Markdown, turning aligned blocks into tables.
Falls back to ``plain_text`` when nothing is confidently tabular.
"""
rows = [sorted(r, key=lambda z: z[1]) for r in token_lines if r]
if len(rows) < min_rows:
return _md_text(plain_text)
if line_height and line_height > 0:
min_gap = max(GAP_FACTOR_H * line_height, 6.0)
else:
widths = [x1 - x0 for r in rows for (_, x0, x1) in r if x1 > x0]
if not widths:
return _md_text(plain_text)
min_gap = max(GAP_FACTOR_W * median(widths), 1.0)
structured = [_has_wide_gap(r, min_gap) for r in rows]
segments: list[str] = []
buf: list[str] = []
found_table = False
def flush_buf():
if buf:
segments.append(" \n".join(_md_line(x) for x in buf if x.strip()))
buf.clear()
i, n = 0, len(rows)
while i < n:
if structured[i]:
j = i
while j < n and structured[j]:
j += 1
run = rows[i:j]
seps = _column_separators(run, min_gap) if len(run) >= min_rows else []
if len(run) >= min_rows and len(seps) + 1 >= min_cols:
grid = _build_grid(run, seps)
# absorb up to 2 following short non-table lines that are a cell
# which wrapped to a new physical line (their x lands inside the
# table's column span) into the last row's matching column
t_lo = min(x0 for r in run for (_, x0, _) in r)
t_hi = max(x1 for r in run for (_, _, x1) in r)
k, absorbed = j, 0
while (k < n and not structured[k] and absorbed < 2
and 1 <= len(rows[k]) <= 2
and all(t_lo <= (x0 + x1) / 2 <= t_hi
for (_, x0, x1) in rows[k])):
for (t, x0, x1) in rows[k]:
c = min(_col_of((x0 + x1) / 2, seps), len(grid[-1]) - 1)
grid[-1][c] = (grid[-1][c] + " " + t).strip()
k += 1
absorbed += 1
flush_buf()
segments.append(_grid_md(grid))
found_table = True
i = k
else:
buf.extend(_row_text(r) for r in run)
i = j
else:
buf.append(_row_text(rows[i]))
i += 1
flush_buf()
if not found_table:
return _md_text(plain_text)
return "\n\n".join(s for s in segments if s.strip())
# --------------------------------------------------------------------------
if __name__ == "__main__": # quick self-test (no deps)
H = 24
def tok(text, x0, w=60):
return (text, float(x0), float(x0 + w))
table = [
[tok("Name", 0), tok("Age", 200), tok("City", 400)],
[tok("Alice", 0), tok("30", 200), tok("Paris", 400)],
[tok("Bob", 0), tok("25", 200), tok("Rome", 400)],
]
print("=== TABLE (expect grid) ===")
print(layout_to_markdown(table, "PLAIN_FALLBACK", line_height=H))
prose = [
[tok("the", 0), tok("quick", 70), tok("brown", 150), tok("fox", 250)],
[tok("jumps", 0), tok("over", 90), tok("a", 170), tok("lazy", 210)],
[tok("dog", 0), tok("near", 80), tok("the", 180), tok("river", 240)],
]
print("\n=== PROSE (expect PLAIN_FALLBACK) ===")
print(layout_to_markdown(prose, "PLAIN_FALLBACK", line_height=H))
invoice = [
[tok("INVOICE", 0, 200)],
[tok("Mytreyi,", 0, 90), tok("Mandala", 95, 80), tok("Artist", 180, 70)],
[tok("Aesthetic", 0, 100), tok("Indian", 105, 70), tok("Crafts", 180, 70)],
[tok("Ph No: 9963467064", 0, 220), tok("Invoice No: 0146", 700, 200)],
[tok("Date: 13th June 2026", 700, 200)],
[tok("Billed to", 0, 100)],
[tok("Taneira,", 0, 90), tok("jubilee", 95, 70), tok("hills", 170, 50)],
[tok("Item Details", 0, 150), tok("Qty.", 320, 60),
tok("Unit Price", 520, 130), tok("Total", 780, 90)],
[tok("Workshop: Mandala Art", 0, 230), tok("16", 320, 40),
tok("300/-", 520, 70), tok("4800/-", 780, 90)],
[tok("Time: 4pm-6pm", 0, 160), tok("(No. of", 320, 90),
tok("(Per head)", 520, 110)],
[tok("participants)", 320, 110)],
[tok("Thank", 0, 70), tok("you", 75, 50), tok("for", 130, 40),
tok("hosting", 175, 90), tok("this", 270, 50), tok("workshop.", 325, 120)],
]
print("\n=== INVOICE (expect prose lines + a grid for the table) ===")
print(layout_to_markdown(invoice, "PLAIN_FALLBACK", line_height=H))