Download rendering.py from ATH-MaaS/OvisOCR3: direct link, hf CLI and curl.
- Browser
- Download file 17.9 kB
-
https://huggingface.co/spaces/ATH-MaaS/OvisOCR3/resolve/main/rendering.py
- Command line
-
hf download hf://spaces/ATH-MaaS/OvisOCR3/rendering.py
-
curl -L -o rendering.py https://huggingface.co/spaces/ATH-MaaS/OvisOCR3/resolve/main/rendering.py
17.9 kB
| """Display-only projections. Raw model output is never rewritten.""" | |
| import base64 | |
| import html | |
| import io | |
| import json | |
| import re | |
| import bleach | |
| from bs4 import BeautifulSoup | |
| from markdown_it import MarkdownIt | |
| from PIL import ImageDraw, ImageFont | |
| from catalog import ROOT | |
| from presentation import TASK_VIEWS, JSON_TASKS | |
| from structured_json import strict_json_loads | |
| COLORS = ["#0d9488", "#e05a47", "#7659b4", "#b88712", "#2876ba"] | |
| RESULT_CSS = (ROOT / "assets/result.css").read_text().replace("__ASSETS__", str(ROOT / "assets")) | |
| CROP = re.compile(r"(?:page_(\d+)/)?images/bbox_(\d+)_(\d+)_(\d+)_(\d+)\.jpg") | |
| TAGS = {"p", "div", "span", "br", "hr", "h1", "h2", "h3", "h4", "h5", "h6", "b", "strong", "i", "em", "u", "s", "sub", "sup", "ul", "ol", "li", "blockquote", "pre", "code", "table", "thead", "tbody", "tfoot", "tr", "th", "td", "img"} | |
| def parse_json(raw): | |
| text = raw.strip() | |
| fence = re.fullmatch(r"```(?:json)?\s*\n(.*?)\n```", text, re.S | re.I) | |
| return strict_json_loads(fence[1] if fence else text) | |
| def output_structure_error(task, data): | |
| arrays = {"document_structured", "document_layout", "figure_captioning", "word_spotting", | |
| "reference_grounding", "crosspage_table"} | |
| expected = (dict, list) if task == "kie_schema" else (list,) if task in arrays else (dict,) | |
| if not isinstance(data, expected): | |
| name = "an object or array" if len(expected) == 2 else "an array" if list in expected else "an object" | |
| return f"Expected a JSON {name.removeprefix('an ')} for this task." | |
| if task in arrays and not all(isinstance(item, dict) for item in data): | |
| return "Expected a JSON array of objects for this task." | |
| return None | |
| def bbox(value): | |
| if isinstance(value, str): | |
| match = re.fullmatch(r"\s*[\[(]\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*[\])]\s*", value) | |
| if not match: | |
| raise ValueError("Invalid bounding box string.") | |
| value = list(map(int, match.groups())) | |
| if not isinstance(value, (list, tuple)) or len(value) != 4 or not all(type(v) is int for v in value): | |
| raise ValueError("Invalid bounding box.") | |
| x1, y1, x2, y2 = value | |
| if not (0 <= x1 < x2 <= 1000 and 0 <= y1 < y2 <= 1000): | |
| raise ValueError("Bounding box is out of range or empty.") | |
| return value | |
| def crop_image(image, value): | |
| x1, y1, x2, y2 = bbox(value) | |
| w, h = image.size | |
| return image.crop((round(x1*w/1000), round(y1*h/1000), round(x2*w/1000), round(y2*h/1000))) | |
| def image_uri(image): | |
| output = io.BytesIO() | |
| image.convert("RGB").save(output, format="JPEG", quality=88) | |
| return "data:image/jpeg;base64," + base64.b64encode(output.getvalue()).decode("ascii") | |
| def boxed_answer_rule(state, silent): | |
| if not state.src.startswith(r"\boxed", state.pos): | |
| return False | |
| opening = state.pos + len(r"\boxed") | |
| while opening < state.posMax and state.src[opening].isspace(): | |
| opening += 1 | |
| if opening >= state.posMax or state.src[opening] != "{": | |
| return False | |
| depth, end = 1, opening + 1 | |
| # Balance nested TeX groups without consuming escaped braces or code spans. | |
| while end < state.posMax: | |
| char = state.src[end] | |
| if char == "\\": | |
| end += 2 | |
| continue | |
| if char == "{": | |
| depth += 1 | |
| elif char == "}": | |
| depth -= 1 | |
| if depth == 0: | |
| if not silent: | |
| token = state.push("boxed_answer", "", 0) | |
| token.content = state.src[opening + 1:end] | |
| state.pos = end + 1 | |
| return True | |
| end += 1 | |
| return False | |
| def boxed_answer_html(tokens, index, options, env): | |
| content = tokens[index].content | |
| # Plain-language answers retain spaces and upright text; TeX remains TeX. | |
| if re.search(r"[\\{}_^]", content): | |
| content = r"\(" + content + r"\)" | |
| return '<span class="boxed-answer">' + html.escape(content) + '</span>' | |
| def markdown_html(text, *, boxed_answers=False): | |
| # Protect math before Markdown consumes backslashes and underscores. | |
| expressions = [] | |
| def protect(match): | |
| expressions.append(match[0]) | |
| return f"OVISMATHPLACEHOLDER{len(expressions)-1}END" | |
| text = re.sub(r"\$\$[\s\S]+?\$\$|\\\[[\s\S]+?\\\]|\\\([\s\S]+?\\\)|(?<![\\\w])\$(?!\s|\$)[^$\n]+?(?<!\s)\$(?!\w)", protect, text) | |
| markdown = MarkdownIt("commonmark", {"html": True, "breaks": True}).enable("table") | |
| if boxed_answers: | |
| markdown.inline.ruler.before("escape", "boxed_answer", boxed_answer_rule) | |
| markdown.renderer.rules["boxed_answer"] = boxed_answer_html | |
| rendered = markdown.render(text) | |
| for index, expression in enumerate(expressions): | |
| rendered = rendered.replace(f"OVISMATHPLACEHOLDER{index}END", html.escape(expression)) | |
| return rendered | |
| def sanitize(content, images): | |
| soup = BeautifulSoup(content, "html.parser") | |
| for element in soup.find_all(["script", "style", "iframe", "object", "embed", "link", "meta", "svg", "math"]): | |
| element.decompose() | |
| for image in soup.find_all("img"): | |
| match = CROP.fullmatch(str(image.get("src", ""))) | |
| if not match: | |
| image.replace_with("[Unsupported image reference]") | |
| continue | |
| page = int(match[1] or 1) - 1 | |
| try: | |
| if not 0 <= page < len(images): | |
| raise ValueError("Page out of range") | |
| image["src"] = image_uri(crop_image(images[page], list(map(int, match.groups()[1:])))) | |
| image.attrs = {"src": image["src"], "alt": "Document region"} | |
| except (ValueError, IndexError): | |
| image.replace_with("[Invalid image region]") | |
| return bleach.clean(str(soup), tags=TAGS, | |
| attributes={"*": ["class"], "img": ["src", "alt"], "td": ["rowspan", "colspan"], "th": ["rowspan", "colspan"]}, | |
| protocols={"data"}, strip=True) | |
| def display_html(content, images=()): | |
| safe = sanitize(content, images) | |
| # Give wide tables their own scroll area instead of breaking every word. | |
| soup = BeautifulSoup(safe, "html.parser") | |
| for table in soup.find_all("table"): | |
| if table.find_parent("table") is None: | |
| wrapper = soup.new_tag("div", attrs={"class": "table-scroll", "tabindex": "0", "role": "region", "aria-label": "Result table"}) | |
| table.wrap(wrapper) | |
| return str(soup) | |
| def needs_math(content): | |
| return bool(re.search(r'\$|\\\(|\\\[', content)) | |
| def frame_document(content, images=(), math=None): | |
| safe = display_html(content, images) | |
| if math is None: | |
| math = needs_math(safe) | |
| mathjax_path = f"./gradio_api/file={ROOT / 'assets' / 'mathjax.js'}" | |
| document = '''<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> | |
| <style>body{margin:0;padding:24px;color:#242a30;background:white;font:15px/1.65 system-ui,sans-serif;overflow-wrap:anywhere;letter-spacing:0}h1,h2,h3{line-height:1.3;font-size:20px;margin:18px 0 10px}h2{font-size:18px}h3{font-size:16px}p{margin:8px 0 14px}img{max-width:100%;height:auto}table{border-collapse:collapse;max-width:100%;display:block;overflow:auto;margin:14px 0;font-size:13px}td,th{border:1px solid #ccd5d7;padding:7px 10px;min-width:35px;vertical-align:top}th{background:#f0f5f4}pre{white-space:pre-wrap;font:13px/1.6 ui-monospace,monospace;background:#f4f6f6;padding:14px;overflow-wrap:anywhere}blockquote{margin-left:0;padding-left:14px;border-left:3px solid #0d9488}mjx-container[display="true"]{overflow-x:auto;overflow-y:hidden;padding:6px 0}@media(max-width:500px){body{padding:14px;font-size:14px}}</style> | |
| <script>window.MathJax={tex:{inlineMath:[['$','$'],['\\\\(','\\\\)']],displayMath:[['$$','$$'],['\\\\[','\\\\]']]},svg:{fontCache:'local'},options:{enableMenu:false}};</script> | |
| <script defer src="''' + html.escape(mathjax_path, quote=True) + '''"></script></head><body>''' + safe + "</body></html>" | |
| document = document.replace("</head>", "<style>" + RESULT_CSS + "</style></head>") | |
| if not math: | |
| document = re.sub(r"<script[\s\S]*?</script>", "", document) | |
| return document | |
| def frame(content, images=(), math=None): | |
| document = frame_document(content, images, math) | |
| return '<iframe title="Rendered model output" sandbox="allow-scripts" referrerpolicy="no-referrer" style="width:100%;height:570px;border:0;background:white" srcdoc="' + html.escape(document, quote=True) + '"></iframe>' | |
| def pending_output(task, raw="", state="Ready"): | |
| title = TASK_VIEWS[task][1] | |
| if raw: | |
| return frame('<div class="result-meta">' + html.escape(state) + '</div><pre class="stream">' + html.escape(raw) + '</pre>', math=False) | |
| label = "No result yet" if state == "Ready" else state | |
| return frame(f'<div class="empty-result"><div class="empty-glyph"></div><h3>{html.escape(title)}</h3><p>{html.escape(label)}</p></div>', math=False) | |
| def value_html(value): | |
| if isinstance(value, dict): | |
| rows = [] | |
| for key, item in value.items(): | |
| rows.append('<tr><th>' + html.escape(str(key)) + '</th><td>' + value_html(item) + '</td></tr>') | |
| return '<table class="field-table"><tbody>' + ''.join(rows) + '</tbody></table>' | |
| if isinstance(value, list): | |
| if not value: | |
| return '<span class="empty-value">[]</span>' | |
| if all(isinstance(item, dict) for item in value): | |
| keys = list(dict.fromkeys(key for item in value for key in item)) | |
| header = '<tr>' + ''.join('<th>' + html.escape(str(key)) + '</th>' for key in keys) + '</tr>' | |
| rows = ''.join('<tr>' + ''.join('<td>' + (value_html(item[key]) if key in item else '<span class="empty-value">Not returned</span>') + '</td>' for key in keys) + '</tr>' for item in value) | |
| return '<table class="records-table"><thead>' + header + '</thead><tbody>' + rows + '</tbody></table>' | |
| return '<ol>' + ''.join('<li>' + value_html(item) + '</li>' for item in value) + '</ol>' | |
| text = json.dumps(value, ensure_ascii=False) if value is None or isinstance(value, bool) else str(value) | |
| return '<span class="field-value">' + html.escape(text) + '</span>' | |
| def region_rows(data): | |
| records = data if isinstance(data, list) else [data] | |
| rows = [] | |
| for index, record in enumerate(records): | |
| if not isinstance(record, dict): | |
| rows.append('<tr><td>' + str(index + 1) + '</td><td colspan="2">' + value_html(record) + '</td></tr>') | |
| continue | |
| text = record.get("text", record.get("answer", record.get("category", record.get("reference", "")))) | |
| coordinates = html.escape(str(record.get("bbox", "Not returned"))) | |
| rows.append(f'<tr><td><span class="region-number">{index+1:02d}</span></td><td>{html.escape(str(text))}</td><td><code>{coordinates}</code></td></tr>') | |
| return '<table class="regions-table"><thead><tr><th>#</th><th>Content / category</th><th>Coordinates</th></tr></thead><tbody>' + ''.join(rows) + '</tbody></table>' | |
| def annotated(image, data): | |
| result = image.copy() | |
| draw = ImageDraw.Draw(result) | |
| width = max(2, round(result.width / 500)) | |
| try: | |
| font = ImageFont.truetype("DejaVuSans.ttf", max(16, result.width // 70)) | |
| except OSError: | |
| font = ImageFont.load_default() | |
| records = data if isinstance(data, list) else [data] | |
| invalid = 0 | |
| for index, record in enumerate(records): | |
| if not isinstance(record, dict) or "bbox" not in record: | |
| continue | |
| try: | |
| x1, y1, x2, y2 = bbox(record["bbox"]) | |
| except ValueError: | |
| invalid += 1 | |
| continue | |
| color = COLORS[index % len(COLORS)] | |
| x1, x2 = x1*result.width/1000, x2*result.width/1000 | |
| y1, y2 = y1*result.height/1000, y2*result.height/1000 | |
| draw.rectangle((x1, y1, x2, y2), outline=color, width=width) | |
| label = str(index + 1) | |
| box = draw.textbbox((0, 0), label, font=font) | |
| tw, th = box[2]+8, box[3]+5 | |
| ly = max(0, y1-th) | |
| draw.rectangle((x1, ly, x1+tw, ly+th), fill=color) | |
| draw.text((x1+4, ly), label, fill="white", font=font) | |
| return result, invalid | |
| def render_output(task, raw, images): | |
| data, overlays, warnings = None, [], [] | |
| if task in JSON_TASKS: | |
| try: | |
| data = parse_json(raw) | |
| except (ValueError, TypeError) as exc: | |
| warnings.append(f"Invalid JSON: {exc}") | |
| else: | |
| structure_error = output_structure_error(task, data) | |
| if structure_error: | |
| warnings.append(structure_error) | |
| body = ('<div class="warning-panel"><strong>Unexpected output structure</strong>' | |
| + html.escape(structure_error) + '</div><pre>' + html.escape(raw) + '</pre>') | |
| return frame(body, images), overlays, data, warnings | |
| if data is not None and task in {"document_structured", "document_layout", "figure_captioning", "word_spotting", "reference_grounding", "position_aware_vqa"}: | |
| image, invalid = annotated(images[0], data) | |
| overlays.append((image, "Detected regions")) | |
| if invalid: | |
| warnings.append(f"{invalid} invalid bounding box(es); retained in raw output.") | |
| meta = '<div class="result-meta"><strong>' + TASK_VIEWS[task][1] + '</strong><span>' + TASK_VIEWS[task][2] + '</span></div>' | |
| if data is not None and task in {"document_structured", "figure_captioning"} and isinstance(data, list): | |
| parts, context = [], [] | |
| for index, item in enumerate(data): | |
| if not isinstance(item, dict): | |
| context.append(value_html(item)) | |
| continue | |
| content = str(item.get("content", "")) | |
| crop = "" | |
| if "<caption>" in content and "bbox" in item: | |
| try: | |
| coordinates = bbox(item["bbox"]) | |
| ref = "images/bbox_" + "_".join(map(str, coordinates)) + ".jpg" | |
| crop = f'<img src="{ref}">' | |
| except ValueError: | |
| pass | |
| is_caption = "<caption>" in content | |
| content = re.sub(r"<mermaid>([\s\S]*?)</mermaid>", lambda m: "\n```text\n" + m[1] + "\n```\n", content) | |
| content = content.replace("<caption>", "").replace("</caption>", "") | |
| if task == "figure_captioning" and is_caption: | |
| label = html.escape(str(item.get("category", "Figure"))) | |
| parts.append(f'<div class="figure-item"><div class="figure-label">{index+1:02d} / {label}</div>{crop}{markdown_html(content)}</div>') | |
| elif task == "figure_captioning": | |
| context.append(markdown_html(content)) | |
| else: | |
| parts.append('<div class="document-block">' + crop + markdown_html(content) + '</div>') | |
| body = meta + "\n".join(parts) | |
| if context: | |
| body += '<div class="figure-context"><div class="result-meta">Page context</div>' + '\n'.join(context) + '</div>' | |
| elif data is not None and task == "crosspage_table" and isinstance(data, list): | |
| parts = [] | |
| for item in data: | |
| if not isinstance(item, dict): | |
| parts.append(value_html(item)) | |
| continue | |
| pages = item.get("source_pages", []) | |
| badges = ''.join('<span class="table-source">Page ' + html.escape(str(page)) + '</span>' for page in pages) if isinstance(pages, list) else value_html(pages) | |
| parts.append('<div class="merged-table">' + badges + str(item.get("html", "")) + '</div>') | |
| body = meta + '\n'.join(parts) | |
| elif data is not None and task in {"kie_open", "kie_fields", "kie_schema"}: | |
| body = meta + value_html(data) | |
| elif data is not None and task == "chart_parsing" and isinstance(data, dict): | |
| title = html.escape(str(data.get("title", "Chart data"))) | |
| body = meta + '<h2 class="result-title">' + title + '</h2>' | |
| if "values" in data: | |
| body += value_html(data["values"]) | |
| # Show every returned field, including unrecognized chart schema fields. | |
| metadata = {key: value for key, value in data.items() if key not in {"values", "title"}} | |
| if metadata: | |
| body += '<div class="figure-context">' + value_html(metadata) + '</div>' | |
| elif data is not None and task == "position_aware_vqa" and isinstance(data, dict): | |
| body = meta + '<div class="answer">' + html.escape(str(data.get("answer", ""))) + '</div>' + region_rows(data) | |
| elif data is not None and task in {"document_layout", "word_spotting", "reference_grounding"}: | |
| body = meta + region_rows(data) | |
| if task == "document_layout" and overlays: | |
| images = [*images, overlays[0][0]] | |
| body = meta + f'<img class="layout-image" src="page_{len(images)}/images/bbox_0_0_1000_1000.jpg">' + region_rows(data) | |
| elif data is not None: | |
| body = meta + value_html(data) | |
| elif task in JSON_TASKS: | |
| body = '<div class="warning-panel"><strong>Structured output unavailable</strong>Invalid JSON. Original model response:</div><pre>' + html.escape(raw) + '</pre>' | |
| elif task == "formula_recognition": | |
| expression = raw.strip() | |
| if not any(token in expression for token in ("$", r"\[", r"\(")): | |
| expression = r"\[" + expression + r"\]" | |
| body = meta + '<div class="formula">' + markdown_html(expression) + '</div>' | |
| elif task == "image_classification": | |
| body = meta + '<span class="answer-label">' + html.escape(raw) + '</span>' | |
| elif task == "visual_question_answering": | |
| body = meta + '<div class="answer">' + markdown_html(raw, boxed_answers=True) + '</div>' | |
| elif task == "document_markdown": | |
| body = markdown_html(raw) | |
| else: | |
| body = meta + '<pre class="text-output">' + html.escape(raw) + '</pre>' | |
| return frame(body, images), overlays, data, warnings | |