Spaces:
Running
Running
| """ | |
| markdown_formatter.py | |
| βββββββββββββββββββββ | |
| Converts raw PaddleOCR bounding-box results into clean, structured Markdown. | |
| PaddleOCR returns a list of items, each shaped as: | |
| [[x1,y1], [x2,y2], [x3,y3], [x4,y4]], (text, confidence) | |
| We use the bounding boxes to reconstruct reading order, detect key-value | |
| pairs, infer headings, and build Markdown tables β without any extra models. | |
| """ | |
| from __future__ import annotations | |
| import re | |
| from dataclasses import dataclass | |
| from typing import List, Optional, Tuple | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| # Tunables | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| CONFIDENCE_THRESHOLD = 0.55 # Drop lines below this score (garbage characters) | |
| HEADING_MAX_WORDS = 6 # Lines with β€ this many words may be headings | |
| HEADING_MIN_CONF = 0.80 # Headings must be recognised with high confidence | |
| PARA_GAP_MULTIPLIER = 1.6 # Y-gap > avg_line_height Γ this β new paragraph | |
| TABLE_X_TOLERANCE = 40 # Pixels: columns within this X range are "aligned" | |
| TABLE_MIN_ROWS = 2 # Need at least this many rows to form a table | |
| class OcrLine: | |
| """One detected text line with all spatial & recognition metadata.""" | |
| text: str | |
| confidence: float | |
| x_min: float | |
| x_max: float | |
| y_min: float | |
| y_max: float | |
| def height(self) -> float: | |
| return self.y_max - self.y_min | |
| def word_count(self) -> int: | |
| return len(self.text.split()) | |
| def x_center(self) -> float: | |
| return (self.x_min + self.x_max) / 2 | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| # Public entry point | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| def format_ocr_result(raw_result) -> Tuple[str, str]: | |
| """ | |
| Convert raw PaddleOCR output into both a plain-text string and a | |
| structured Markdown string. | |
| Args: | |
| raw_result: The list returned by ocr.ocr(img, cls=True)[0] | |
| Returns: | |
| (plain_text, markdown_text) β both are complete strings | |
| """ | |
| lines = _parse_raw(raw_result) | |
| lines = _filter_low_confidence(lines) | |
| if not lines: | |
| return "", "" | |
| plain_text = "\n".join(l.text for l in lines) | |
| markdown = _build_markdown(lines) | |
| return plain_text, markdown | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| # Parsing | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| def _parse_raw(raw_result) -> List[OcrLine]: | |
| """Turn PaddleOCR raw list into OcrLine objects, sorted top-to-bottom.""" | |
| lines: List[OcrLine] = [] | |
| if not raw_result: | |
| return lines | |
| for item in raw_result: | |
| try: | |
| box, (text, conf) = item | |
| # box is 4 corner points β extract bounding rectangle | |
| xs = [p[0] for p in box] | |
| ys = [p[1] for p in box] | |
| lines.append(OcrLine( | |
| text=text.strip(), | |
| confidence=conf, | |
| x_min=min(xs), | |
| x_max=max(xs), | |
| y_min=min(ys), | |
| y_max=max(ys), | |
| )) | |
| except Exception: | |
| continue | |
| # Sort by vertical position so we read top-to-bottom | |
| lines.sort(key=lambda l: l.y_min) | |
| return lines | |
| def _filter_low_confidence(lines: List[OcrLine]) -> List[OcrLine]: | |
| """Remove lines whose OCR confidence is below the threshold.""" | |
| return [l for l in lines if l.confidence >= CONFIDENCE_THRESHOLD] | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| # Markdown builder | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| def _build_markdown(lines: List[OcrLine]) -> str: | |
| """ | |
| Walk through sorted lines and emit Markdown tokens. | |
| Strategy: | |
| 1. Group lines into paragraphs using Y-gap analysis. | |
| 2. Within each group, attempt to detect a Markdown table. | |
| 3. Otherwise, classify each line as: heading, key-value pair, or body text. | |
| """ | |
| if not lines: | |
| return "" | |
| avg_height = sum(l.height for l in lines) / len(lines) | |
| para_gap = avg_height * PARA_GAP_MULTIPLIER | |
| # Split into paragraph groups | |
| groups: List[List[OcrLine]] = [] | |
| current_group: List[OcrLine] = [lines[0]] | |
| for prev, cur in zip(lines, lines[1:]): | |
| gap = cur.y_min - prev.y_max | |
| if gap > para_gap: | |
| groups.append(current_group) | |
| current_group = [cur] | |
| else: | |
| current_group.append(cur) | |
| groups.append(current_group) | |
| md_parts: List[str] = [] | |
| for group in groups: | |
| table_md = _try_build_table(group) | |
| if table_md: | |
| md_parts.append(table_md) | |
| else: | |
| for line in group: | |
| md_parts.append(_classify_line(line)) | |
| return "\n\n".join(p for p in md_parts if p.strip()) | |
| def _classify_line(line: OcrLine) -> str: | |
| """ | |
| Classify a single line and return its Markdown representation. | |
| Priority: | |
| heading > key-value > body text | |
| """ | |
| # Heading detection: short, high-confidence, uppercase-ish or title-case | |
| if ( | |
| line.word_count <= HEADING_MAX_WORDS | |
| and line.confidence >= HEADING_MIN_CONF | |
| and _looks_like_heading(line.text) | |
| ): | |
| return f"## {line.text}" | |
| # Key-value detection: "Label: Value" or "Label - Value" | |
| kv = _extract_key_value(line.text) | |
| if kv: | |
| key, val = kv | |
| return f"**{key}:** {val}" | |
| # Default: plain body text | |
| return line.text | |
| def _looks_like_heading(text: str) -> bool: | |
| """ | |
| Heuristic: a line looks like a heading if it is mostly uppercase, | |
| or in title case, and contains no sentence-ending punctuation mid-string. | |
| """ | |
| # Strip trailing punctuation for comparison | |
| core = text.rstrip(".:,;") | |
| if not core: | |
| return False | |
| words = core.split() | |
| # Mostly uppercase words (like "GOVERNMENT OF INDIA") | |
| upper_words = sum(1 for w in words if w.isupper() and len(w) > 1) | |
| if upper_words / len(words) >= 0.6: | |
| return True | |
| # Title case (like "Birth Certificate") | |
| title_words = sum(1 for w in words if w.istitle()) | |
| if title_words / len(words) >= 0.7: | |
| return True | |
| return False | |
| def _extract_key_value(text: str) -> Optional[Tuple[str, str]]: | |
| """ | |
| Detect common key-value patterns in OCR'd text. | |
| Examples: | |
| "DOB: 01/06/2007" β ("DOB", "01/06/2007") | |
| "Name of Father - Kumar" β ("Name of Father", "Kumar") | |
| "Aadhaar no.issued:..." β ("Aadhaar no.issued", "...") | |
| """ | |
| # Pattern: anything before ":" or " - " then a value | |
| m = re.match(r'^([A-Za-z][^:\-]{1,40}?)\s*[:\-]\s*(.+)$', text) | |
| if m: | |
| key, val = m.group(1).strip(), m.group(2).strip() | |
| # Reject if key is suspiciously long or value is empty | |
| if val and len(key.split()) <= 6: | |
| return key, val | |
| return None | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| # Table detection | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| def _try_build_table(group: List[OcrLine]) -> Optional[str]: | |
| """ | |
| If lines in a group appear to form columns (aligned X-centers), | |
| reconstruct them as a Markdown table. | |
| Returns the table string or None if no table structure is found. | |
| """ | |
| if len(group) < TABLE_MIN_ROWS: | |
| return None | |
| # Collect all unique X-centers and cluster them into columns | |
| x_centers = [l.x_center for l in group] | |
| columns = _cluster_values(x_centers, TABLE_X_TOLERANCE) | |
| # Need at least 2 distinct columns to form a table | |
| if len(columns) < 2: | |
| return None | |
| # Assign each line to a column by nearest center | |
| # Build a row-major dict: row_y_key β {col_idx: text} | |
| row_map: dict = {} | |
| for line in group: | |
| col_idx = _nearest_cluster(line.x_center, columns) | |
| row_key = round(line.y_min / 15) # bucket by ~15px rows | |
| if row_key not in row_map: | |
| row_map[row_key] = {} | |
| row_map[row_key][col_idx] = line.text | |
| rows = [row_map[k] for k in sorted(row_map.keys())] | |
| n_cols = len(columns) | |
| # Build markdown table | |
| header = rows[0] if rows else {} | |
| header_cells = [header.get(i, "") for i in range(n_cols)] | |
| separator = ["---"] * n_cols | |
| md_rows = [ | |
| "| " + " | ".join(header_cells) + " |", | |
| "| " + " | ".join(separator) + " |", | |
| ] | |
| for row in rows[1:]: | |
| cells = [row.get(i, "") for i in range(n_cols)] | |
| md_rows.append("| " + " | ".join(cells) + " |") | |
| return "\n".join(md_rows) | |
| def _cluster_values(values: List[float], tolerance: float) -> List[float]: | |
| """Group a list of floats into clusters separated by at least `tolerance`.""" | |
| if not values: | |
| return [] | |
| sorted_vals = sorted(set(values)) | |
| clusters: List[float] = [sorted_vals[0]] | |
| for v in sorted_vals[1:]: | |
| if v - clusters[-1] > tolerance: | |
| clusters.append(v) | |
| else: | |
| # Merge into cluster centroid | |
| clusters[-1] = (clusters[-1] + v) / 2 | |
| return clusters | |
| def _nearest_cluster(value: float, clusters: List[float]) -> int: | |
| """Return the index of the nearest cluster to `value`.""" | |
| return min(range(len(clusters)), key=lambda i: abs(clusters[i] - value)) | |