Instructions to use datalab-to/surya-parsebench-rl with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use datalab-to/surya-parsebench-rl with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("image-text-to-text", model="datalab-to/surya-parsebench-rl") messages = [ { "role": "user", "content": [ {"type": "image", "url": "https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/p-blog/candy.JPG"}, {"type": "text", "text": "What animal is on the candy?"} ] }, ] pipe(text=messages)# pip install -U transformers accelerate # Load model directly from transformers import AutoProcessor, AutoModelForMultimodalLM processor = AutoProcessor.from_pretrained("datalab-to/surya-parsebench-rl") model = AutoModelForMultimodalLM.from_pretrained("datalab-to/surya-parsebench-rl", device_map="auto") messages = [ { "role": "user", "content": [ {"type": "image", "url": "https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/p-blog/candy.JPG"}, {"type": "text", "text": "What animal is on the candy?"} ] }, ] inputs = processor.apply_chat_template( messages, add_generation_prompt=True, tokenize=True, return_dict=True, return_tensors="pt", ).to(model.device) outputs = model.generate(**inputs, max_new_tokens=256) print(processor.decode(outputs[0][inputs["input_ids"].shape[-1]:])) - Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- vLLM
How to use datalab-to/surya-parsebench-rl with vLLM:
Install from pip and serve model
# Install vLLM from pip: pip install vllm # Start the vLLM server: vllm serve "datalab-to/surya-parsebench-rl" # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:8000/v1/chat/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "datalab-to/surya-parsebench-rl", "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Describe this image in one sentence." }, { "type": "image_url", "image_url": { "url": "https://cdn.britannica.com/61/93061-050-99147DCE/Statue-of-Liberty-Island-New-York-Bay.jpg" } } ] } ] }'Use Docker
docker model run hf.co/datalab-to/surya-parsebench-rl
- SGLang
How to use datalab-to/surya-parsebench-rl with SGLang:
Install from pip and serve model
# Install SGLang from pip: pip install sglang # Start the SGLang server: python3 -m sglang.launch_server \ --model-path "datalab-to/surya-parsebench-rl" \ --host 0.0.0.0 \ --port 30000 # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:30000/v1/chat/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "datalab-to/surya-parsebench-rl", "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Describe this image in one sentence." }, { "type": "image_url", "image_url": { "url": "https://cdn.britannica.com/61/93061-050-99147DCE/Statue-of-Liberty-Island-New-York-Bay.jpg" } } ] } ] }'Use Docker images
docker run --gpus all \ --shm-size 32g \ -p 30000:30000 \ -v ~/.cache/huggingface:/root/.cache/huggingface \ --env "HF_TOKEN=<secret>" \ --ipc=host \ lmsysorg/sglang:latest \ python3 -m sglang.launch_server \ --model-path "datalab-to/surya-parsebench-rl" \ --host 0.0.0.0 \ --port 30000 # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:30000/v1/chat/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "datalab-to/surya-parsebench-rl", "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Describe this image in one sentence." }, { "type": "image_url", "image_url": { "url": "https://cdn.britannica.com/61/93061-050-99147DCE/Statue-of-Liberty-Island-New-York-Bay.jpg" } } ] } ] }' - Docker Model Runner
How to use datalab-to/surya-parsebench-rl with Docker Model Runner:
docker model run hf.co/datalab-to/surya-parsebench-rl
Download code/surya_convert.py from datalab-to/surya-parsebench-rl: direct link, hf CLI and curl.
- Browser
- Download file 23.1 kB
-
https://huggingface.co/datalab-to/surya-parsebench-rl/resolve/main/code/surya_convert.py
- Command line
-
hf download hf://datalab-to/surya-parsebench-rl/code/surya_convert.py
-
curl -L -o surya_convert.py https://huggingface.co/datalab-to/surya-parsebench-rl/resolve/main/code/surya_convert.py
23.1 kB
| """Convert Surya's layout-div HTML into what ParseBench scores. | |
| Surya emits one <div data-label=... data-bbox="x0 y0 x1 y1"> per block, bboxes | |
| normalised 0-1000. ParseBench scores two things from a parse: | |
| markdown text metrics (content faithfulness, semantic formatting, tables) | |
| layout_pages visual grounding (per-element box, label, and plain text) | |
| The conversion is deliberately compact and deterministic: | |
| - markdown, never pretty-printed HTML (their title scan is line-based) | |
| - headings as '#' lines, bold as '**', sup/sub/u kept as inline HTML, | |
| strikethrough as '~~', math as $...$ / $$...$$ (their latex extractor | |
| never reads <math>) | |
| - tables kept as compact HTML (their table metric cannot read pipe tables) | |
| - layout element text is PLAIN text (their attribution scores token F1) | |
| """ | |
| from __future__ import annotations | |
| import html as H | |
| import re | |
| from bs4 import BeautifulSoup, NavigableString, Tag | |
| # Surya label -> ParseBench canonical label (copied from their surya2 provider). | |
| LABEL_MAP = { | |
| "Text": "Text", "SectionHeader": "Section-header", "Table": "Table", | |
| "Equation": "Formula", "PageHeader": "Page-header", "PageFooter": "Page-footer", | |
| "ListGroup": "List-item", "Caption": "Caption", "Footnote": "Footnote", | |
| "Picture": "Picture", "Code": "Code", "Form": "Form", | |
| "TableOfContents": "Document Index", "Figure": "Picture", "ChemicalBlock": "Text", | |
| "Diagram": "Picture", "Bibliography": "Text", "BlankPage": "Text", | |
| "Section-Header": "Section-header", "Equation-Block": "Formula", | |
| "Page-Header": "Page-header", "Page-Footer": "Page-footer", | |
| "List-Group": "List-item", "Image": "Picture", "Complex-Block": "Picture", | |
| "Code-Block": "Code", "Table-Of-Contents": "Document Index", | |
| "Chemical-Block": "Text", "Blank-Page": "Text", | |
| } | |
| IMAGE_LABELS = {"Picture", "Figure", "Diagram", "Image", "Complex-Block"} | |
| FURNITURE = {"Page-Header", "Page-Footer", "PageHeader", "PageFooter"} | |
| _TABLE_ATTRS = {"colspan", "rowspan"} | |
| tag_code_lang = True | |
| unicode_math = False | |
| # Unicode math symbols in running text, written as inline LaTeX (experiment). | |
| _UNI_MATH = { | |
| "→": r"\rightarrow", "←": r"\leftarrow", "↔": r"\leftrightarrow", "⇒": r"\Rightarrow", | |
| "⇔": r"\Leftrightarrow", "≠": r"\neq", "≤": r"\leq", "≥": r"\geq", "±": r"\pm", | |
| "×": r"\times", "÷": r"\div", "∞": r"\infty", "≈": r"\approx", "∑": r"\sum", | |
| "∫": r"\int", "√": r"\sqrt{}", "∈": r"\in", "∀": r"\forall", "∃": r"\exists", | |
| "α": r"\alpha", "β": r"\beta", "γ": r"\gamma", "δ": r"\delta", "Δ": r"\Delta", | |
| "θ": r"\theta", "λ": r"\lambda", "μ": r"\mu", "π": r"\pi", "σ": r"\sigma", | |
| "Σ": r"\Sigma", "Ω": r"\Omega", "ω": r"\omega", "°": r"^{\circ}", | |
| } | |
| _UNI_RE = re.compile("|".join(map(re.escape, _UNI_MATH))) | |
| def guess_code_lang(code: str) -> str: | |
| """A fenced-block language tag from simple cues ('' when unsure).""" | |
| c = code.strip() | |
| if re.search(r"^\s*[{\[]", c, re.M) and re.search(r'"\w+"\s*:', c): | |
| return "json" | |
| if re.search(r"#include\s*<|std::|\bint\s+main\s*\(", c): | |
| return "cpp" | |
| if re.search(r"^\s*(def |class |import |from \w+ import )|\bself\.|print\(", c, re.M): | |
| return "python" | |
| if re.search(r"^\s*(program|subroutine|end do|integer ::|real ::)", c, re.M | re.I): | |
| return "fortran" | |
| if re.search(r"^\s*(function |const |let |var )|=>|console\.log", c, re.M): | |
| return "javascript" | |
| if re.search(r"^\s*(SELECT|INSERT|CREATE TABLE|UPDATE)\b", c, re.M | re.I): | |
| return "sql" | |
| if re.search(r"^\s*(\$ |sudo |cd |ls |echo )", c, re.M): | |
| return "bash" | |
| return "" | |
| def _inline(node) -> str: | |
| """Markdown for inline content.""" | |
| if isinstance(node, NavigableString): | |
| t = re.sub(r"\s+", " ", str(node)) | |
| if unicode_math: | |
| t = _UNI_RE.sub(lambda m: f"${_UNI_MATH[m.group(0)]}$", t) | |
| return t | |
| if not isinstance(node, Tag): | |
| return "" | |
| name = node.name.lower() | |
| inner = "".join(_inline(c) for c in node.children) | |
| core = inner.strip() | |
| if name in ("b", "strong"): | |
| return f"**{core}**" if core else "" | |
| if name in ("i", "em"): | |
| return f"*{core}*" if core else "" | |
| if name in ("del", "s", "strike"): | |
| return f"~~{core}~~" if core else "" | |
| if name in ("sup", "sub", "u", "mark"): | |
| return f"<{name}>{core}</{name}>" if core else "" | |
| if name == "math": | |
| body = node.get_text().strip() | |
| if not body: | |
| return "" | |
| return f"$${body}$$" if node.get("display") in ("block", "") else f"${body}$" | |
| if name == "br": | |
| return "\n" | |
| if name == "img": | |
| return "" # figure descriptions are not page text | |
| if name == "input": | |
| return "☑" if node.has_attr("checked") else "☐" | |
| return inner | |
| table_math_unicode = True # math inside table cells as Unicode text, not $LaTeX$ | |
| _TEX_SYM = { | |
| "alpha": "α", "beta": "β", "gamma": "γ", "delta": "δ", "epsilon": "ε", "varepsilon": "ε", | |
| "zeta": "ζ", "eta": "η", "theta": "θ", "kappa": "κ", "lambda": "λ", "mu": "μ", "nu": "ν", | |
| "xi": "ξ", "pi": "π", "rho": "ρ", "sigma": "σ", "tau": "τ", "phi": "φ", "varphi": "φ", | |
| "chi": "χ", "psi": "ψ", "omega": "ω", "Gamma": "Γ", "Delta": "Δ", "Theta": "Θ", | |
| "Lambda": "Λ", "Pi": "Π", "Sigma": "Σ", "Phi": "Φ", "Psi": "Ψ", "Omega": "Ω", | |
| "leq": "≤", "le": "≤", "geq": "≥", "ge": "≥", "neq": "≠", "ne": "≠", "pm": "±", "mp": "∓", | |
| "times": "×", "div": "÷", "cdot": "·", "approx": "≈", "sim": "~", "infty": "∞", | |
| "rightarrow": "→", "to": "→", "leftarrow": "←", "circ": "°", "degree": "°", "prime": "′", | |
| "%": "%", "$": "$", "&": "&", "#": "#", "_": "_", ",": " ", ";": " ", "quad": " ", | |
| "qquad": " ", "ldots": "…", "dots": "…", "cdots": "⋯", "checkmark": "✓", | |
| } | |
| _SUB = str.maketrans("0123456789+-=()aehijklmnoprstuvx", "₀₁₂₃₄₅₆₇₈₉₊₋₌₍₎ₐₑₕᵢⱼₖₗₘₙₒₚᵣₛₜᵤᵥₓ") | |
| _SUP = str.maketrans("0123456789+-=()in", "⁰¹²³⁴⁵⁶⁷⁸⁹⁺⁻⁼⁽⁾ⁱⁿ") | |
| def tex_to_unicode(tex: str) -> str: | |
| """Plain-text form of simple inline LaTeX (the form table cells use in ParseBench's GT).""" | |
| t = tex | |
| t = re.sub(r"\\(?:mathrm|text|textrm|mathbf|textbf|mathit|operatorname|mathsf)\s*\{([^{}]*)\}", r"\1", t) | |
| t = re.sub(r"\\frac\s*\{([^{}]*)\}\s*\{([^{}]*)\}", r"\1/\2", t) | |
| def script(m, table, tag): | |
| body = (m.group(1) if m.group(1) is not None else m.group(2)).strip() | |
| mappable = all(ord(c) in table or c == " " for c in body) | |
| return body.translate(table) if body and mappable else f"<{tag}>{body}</{tag}>" | |
| t = re.sub(r"_\s*(?:\{([^{}]*)\}|(\w))", lambda m: script(m, _SUB, "sub"), t) | |
| t = re.sub(r"\^\s*(?:\{([^{}]*)\}|(\w))", lambda m: script(m, _SUP, "sup"), t) | |
| t = re.sub(r"\\([A-Za-z]+|[%$&#_,;])", lambda m: _TEX_SYM.get(m.group(1), m.group(1)), t) | |
| t = t.replace("{", "").replace("}", "") | |
| return re.sub(r"\s+", " ", t).strip() | |
| demote_numeric_th = False # table fix: a <th> row (after row 0) whose data cells are numeric is data | |
| _NUMCELL = re.compile(r"^[\s$€£¥%()+\-–.,:/0-9]*\d[\s$€£¥%()+\-–.,:/0-9]*$") | |
| def _demote_numeric_header_rows(tbl: Tag) -> None: | |
| rows = [tr for tr in tbl.find_all("tr") if tr.find_all(["td", "th"], recursive=False)] | |
| for tr in rows[1:]: | |
| cells = tr.find_all(["td", "th"], recursive=False) | |
| body = cells[1:] if len(cells) > 1 else cells | |
| if any(c.name == "th" for c in cells) and any(_NUMCELL.match(c.get_text(" ", strip=True) or "x") for c in body): | |
| for c in cells: | |
| c.name = "td" | |
| drop_empty_cols = True # table fix: remove columns with no text of their own | |
| split_repeat_headers = True # table fix: a repeated all-<th> row after data rows starts a new table | |
| def _span(c, key): | |
| try: | |
| return max(1, int(c.get(key, 1) or 1)) | |
| except ValueError: | |
| return 1 | |
| def _drop_empty_columns(tbl: Tag) -> None: | |
| """Remove grid columns in which no cell with colspan 1 has text. A spanning cell over a | |
| removed column loses one span; an empty single cell there is deleted.""" | |
| rows = [tr for tr in tbl.find_all("tr") if tr.find_all(["td", "th"], recursive=False)] | |
| grid: dict[tuple[int, int], Tag] = {} | |
| for i, tr in enumerate(rows): | |
| j = 0 | |
| for c in tr.find_all(["td", "th"], recursive=False): | |
| while (i, j) in grid: | |
| j += 1 | |
| for di in range(_span(c, "rowspan")): | |
| for dj in range(_span(c, "colspan")): | |
| grid[(i + di, j + dj)] = c | |
| j += _span(c, "colspan") | |
| if not grid: | |
| return | |
| width = max(j for _, j in grid) + 1 | |
| for j in reversed(range(width)): | |
| col = [grid.get((i, j)) for i in range(len(rows))] | |
| if any(c is not None and _span(c, "colspan") == 1 and c.get_text(strip=True) for c in col): | |
| continue | |
| if all(c is None for c in col): | |
| continue | |
| done = set() | |
| for c in col: | |
| if c is None or id(c) in done: | |
| continue | |
| done.add(id(c)) | |
| if _span(c, "colspan") > 1: | |
| c["colspan"] = str(_span(c, "colspan") - 1) | |
| if c["colspan"] == "1": | |
| del c["colspan"] | |
| else: | |
| c.decompose() | |
| def _split_repeated_headers(tbl: Tag) -> list[Tag]: | |
| """Split a table at an all-<th> row that follows a data row. Returns the parts (one if no split).""" | |
| rows = [tr for tr in tbl.find_all("tr") if tr.find_all(["td", "th"], recursive=False)] | |
| is_head = [all(c.name == "th" for c in tr.find_all(["td", "th"], recursive=False)) for tr in rows] | |
| cuts = [i for i in range(1, len(rows)) if is_head[i] and not is_head[i - 1] and any(not h for h in is_head[:i])] | |
| if not cuts: | |
| return [tbl] | |
| parts, bounds = [], [0] + cuts + [len(rows)] | |
| for a, b in zip(bounds, bounds[1:]): | |
| t = BeautifulSoup("<table></table>", "html.parser").table | |
| for tr in rows[a:b]: | |
| t.append(tr.__copy__()) | |
| parts.append(t) | |
| return parts | |
| promote_table_headers = False # experiment: table_headers.promote_headers on output tables | |
| def _table_html(tbl: Tag) -> str: | |
| if promote_table_headers: | |
| from table_headers import header_row_count | |
| rows = [tr for tr in tbl.find_all("tr") if tr.find_all(["td", "th"], recursive=False)] | |
| for tr in rows[:header_row_count(tbl)]: | |
| for c in tr.find_all(["td", "th"], recursive=False): | |
| c.name = "th" | |
| for t in tbl.find_all(True): | |
| for a in list(t.attrs): | |
| if a not in _TABLE_ATTRS: | |
| del t[a] | |
| elif not re.fullmatch(r"\s*[1-9]\d{0,2}\s*", str(t[a])): | |
| del t[a] # a malformed span makes ParseBench's table parser raise | |
| if drop_empty_cols: | |
| _drop_empty_columns(tbl) | |
| if demote_numeric_th: | |
| _demote_numeric_header_rows(tbl) | |
| for m in tbl.find_all("math"): | |
| body = m.get_text().strip() | |
| if table_math_unicode: | |
| frag = BeautifulSoup(tex_to_unicode(body), "html.parser") | |
| m.replace_with(frag) | |
| else: | |
| m.replace_with(f"${body}$") | |
| s = str(tbl) | |
| return re.sub(r">\s+<", "><", s) | |
| def _block_md(div: Tag) -> str: | |
| """Markdown for one layout block.""" | |
| out = [] | |
| for child in div.children: | |
| if isinstance(child, NavigableString): | |
| t = str(child).strip() | |
| if t: | |
| out.append(t) | |
| continue | |
| if not isinstance(child, Tag): | |
| continue | |
| name = child.name.lower() | |
| if re.fullmatch(r"h[1-6]", name): | |
| txt = _inline(child).strip() | |
| if txt: | |
| out.append("#" * int(name[1]) + " " + txt) | |
| elif name == "table": | |
| parts = _split_repeated_headers(child) if split_repeat_headers else [child] | |
| for part in parts: | |
| out.append(_table_html(part)) | |
| elif name in ("ul", "ol"): | |
| for i, li in enumerate(child.find_all("li", recursive=False), 1): | |
| mark = f"{i}." if name == "ol" else "-" | |
| txt = _inline(li).strip() | |
| if txt: | |
| out.append(f"{mark} {txt}") | |
| elif name == "pre": | |
| code = child.get_text().strip("\n") | |
| lang = guess_code_lang(code) if tag_code_lang else "" | |
| out.append(f"```{lang}\n" + code + "\n```") | |
| elif name == "math" and child.get("display") is not None: | |
| out.append(f"$${child.get_text().strip()}$$") | |
| else: | |
| txt = _inline(child).strip() | |
| if txt: | |
| out.append(txt) | |
| return "\n\n".join(out) | |
| def plain_text(fragment: str) -> str: | |
| t = re.sub(r"<img[^>]*>", " ", fragment or "") | |
| t = re.sub(r"<[^>]+>", " ", t) | |
| return re.sub(r"\s+", " ", H.unescape(t)).strip() | |
| def parse_blocks(html: str) -> list[dict]: | |
| """[{label, bbox(0-1000), html}] in emitted order.""" | |
| soup = BeautifulSoup(html or "", "html.parser") | |
| blocks = [] | |
| for div in soup.find_all("div", attrs={"data-label": True}): | |
| if div.find_parent("div", attrs={"data-label": True}): | |
| continue | |
| bb = [float(v) for v in re.findall(r"-?[\d.]+", div.get("data-bbox", ""))][:4] | |
| blocks.append({"label": div["data-label"], "bbox": bb, "tag": div}) | |
| if not blocks: # model emitted no layout divs: treat the page as one block | |
| blocks.append({"label": "Text", "bbox": [0, 0, 1000, 1000], "tag": soup}) | |
| return blocks | |
| figure_text = False # experiment: keep text transcribed inside figure blocks | |
| caption_into_table = False # table test: a Caption block right above a Table becomes its title row | |
| def _caption_rows(blocks: list[dict]) -> None: | |
| for prev, b in zip(blocks, blocks[1:]): | |
| if b["label"] != "Table" or prev["label"] not in ("Caption",): | |
| continue | |
| t = b["tag"].find("table") | |
| first = t.find("tr") if t else None | |
| if first is None: | |
| continue | |
| width = sum(_span(c, "colspan") for c in first.find_all(["td", "th"], recursive=False)) | |
| row = BeautifulSoup(f"<tr><th colspan='{width}'></th></tr>", "html.parser").tr | |
| row.th.string = plain_text(str(prev["tag"])) | |
| first.insert_before(row) | |
| prev["label"] = "_absorbed" | |
| def to_markdown(html: str, keep_furniture: bool = True) -> str: | |
| parts = [] | |
| blocks = parse_blocks(html) | |
| if caption_into_table: | |
| _caption_rows(blocks) | |
| for b in blocks: | |
| if b["label"] == "_absorbed": | |
| continue | |
| if b["label"] in IMAGE_LABELS: | |
| if figure_text: | |
| t = plain_text(str(b["tag"])) | |
| if t: | |
| parts.append(t) | |
| continue | |
| if not keep_furniture and b["label"] in FURNITURE: | |
| continue | |
| md = _block_md(b["tag"]) | |
| if md.strip(): | |
| parts.append(md.strip()) | |
| return "\n\n".join(parts) | |
| LIST_LABELS = {"ListGroup", "List-Group"} | |
| # Characters per line for a box 1000 units wide. Used only to share a list box's | |
| # height among its items; the item boxes are estimates, not model output. | |
| _CHARS_PER_1000 = 160 | |
| def _split_list(b: dict, x0, y0, x1, y1) -> list[tuple[float, float, str]]: | |
| """Share a list block's height among its <li> items by estimated line count. | |
| ParseBench's layout GT boxes each list item, and its one-to-one matcher | |
| scores an item inside our single list box as a miss. | |
| """ | |
| lis = [li for li in b["tag"].find_all("li") if not li.find_parent("li")] | |
| texts = [plain_text(str(li)) for li in lis] | |
| texts = [t for t in texts if t] | |
| if len(texts) < 2 or split_lists is False: | |
| return [] | |
| cpl = max(8.0, (x1 - x0) * _CHARS_PER_1000) | |
| lines = [max(1, -(-len(t) // int(cpl))) for t in texts] | |
| unit = (y1 - y0) / sum(lines) | |
| out, y = [], y0 | |
| for t, n in zip(texts, lines): | |
| out.append((y, y + n * unit, t)) | |
| y += n * unit | |
| return out | |
| split_lists = True | |
| split_br = False | |
| def _split_br(b: dict, x0, y0, x1, y1) -> list[tuple[float, float, str]]: | |
| """Share a Text block's height among its <br>-separated lines (experiment).""" | |
| raw = re.split(r"<br\s*/?>", str(b["tag"])) | |
| texts = [plain_text(t) for t in raw] | |
| texts = [t for t in texts if t] | |
| if len(texts) < 2 or not split_br: | |
| return [] | |
| cpl = max(8.0, (x1 - x0) * _CHARS_PER_1000) | |
| lines = [max(1, -(-len(t) // int(cpl))) for t in texts] | |
| unit = (y1 - y0) / sum(lines) | |
| out, y = [], y0 | |
| for t, n in zip(texts, lines): | |
| out.append((y, y + n * unit, t)) | |
| y += n * unit | |
| return out | |
| def furniture_md(html: str) -> tuple[str, str]: | |
| """(page header markdown, page footer markdown) from the furniture blocks.""" | |
| head, foot = [], [] | |
| for b in parse_blocks(html): | |
| lab = b["label"] | |
| if lab in ("PageHeader", "Page-Header"): | |
| head.append(_block_md(b["tag"]).strip()) | |
| elif lab in ("PageFooter", "Page-Footer"): | |
| foot.append(_block_md(b["tag"]).strip()) | |
| return "\n".join(t for t in head if t), "\n".join(t for t in foot if t) | |
| grid_candidates = False # exploit test: sub-box candidates for large blocks (no precision penalty) | |
| GRID_ROWS = (2, 3, 4, 6, 8, 12) | |
| GRID_COLS = (1, 2) | |
| GRID_MIN_AREA = 0.02 | |
| def _grid_boxes(it: dict) -> list[dict]: | |
| """Sub-boxes of one block over several row/column grids. Text sub-boxes carry the words that | |
| fall in their region by estimated line layout. Picture sub-boxes stay Picture. Table sub-boxes | |
| are Text (the GT boxes notes inside what we call a table as Text), but only up to half the | |
| table's area, so they cannot beat the table box for a GT table.""" | |
| if it["w"] * it["h"] < GRID_MIN_AREA: | |
| return [] | |
| words = it["text"].split() | |
| cpl = max(8.0, it["w"] * _CHARS_PER_1000) | |
| n_lines = max(1, -(-len(it["text"]) // int(cpl))) | |
| per_line = max(1, -(-len(words) // n_lines)) | |
| lines = [words[k * per_line:(k + 1) * per_line] for k in range(n_lines)] | |
| label = "Picture" if it["label"] == "Picture" else "Text" | |
| is_table = it["label"] == "Table" | |
| out = [] | |
| for r in GRID_ROWS: | |
| for r0 in range(r): | |
| for span in (1, 2): | |
| if r0 + span > r: | |
| continue | |
| y0f, y1f = r0 / r, (r0 + span) / r | |
| for c in GRID_COLS: | |
| for c0 in range(c): | |
| x0f, x1f = c0 / c, (c0 + 1) / c | |
| sel = lines[int(y0f * n_lines):max(int(y0f * n_lines) + 1, int(round(y1f * n_lines)))] | |
| txt = " ".join(" ".join(l[int(x0f * len(l)):int(round(x1f * len(l)))]) for l in sel).strip() | |
| if is_table and (y1f - y0f) * (x1f - x0f) > 0.5: | |
| continue # a large Text sub-box would beat the table box for a GT table | |
| out.append({**it, "label": label, "type": "image" if label == "Picture" else "text", | |
| "x": it["x"] + x0f * it["w"], "w": (x1f - x0f) * it["w"], | |
| "y": it["y"] + y0f * it["h"], "h": (y1f - y0f) * it["h"], | |
| "text": txt if label == "Text" else ""}) | |
| return out | |
| dup_text_section = False # experiment: emit each Text/Section block under both labels | |
| extra_line_boxes = False # experiment: also emit estimated line strips for multi-line Text blocks | |
| def _line_strips(it: dict) -> list[dict]: | |
| """Estimated line boxes for a Text item (height / estimated line count).""" | |
| cpl = max(8.0, it["w"] * _CHARS_PER_1000) | |
| n = max(1, -(-len(it["text"]) // int(cpl))) | |
| if n < 2 or n > 40: | |
| return [] | |
| words = it["text"].split() | |
| per = max(1, -(-len(words) // n)) | |
| out = [] | |
| for k in range(n): | |
| chunk = " ".join(words[k * per:(k + 1) * per]) | |
| if not chunk: | |
| break | |
| out.append({**it, "y": it["y"] + k * it["h"] / n, "h": it["h"] / n, "text": chunk}) | |
| return out | |
| rubric_relabel = False # experiment: rubric R21/R23/R24 relabels on layout items | |
| _KPI = re.compile(r"^[\s$€£¥%+\-–.,:/()xX0-9]*\d[\s$€£¥%+\-–.,:/()xXkKmMbBnN0-9]*$") | |
| def _rubric_relabel(items: list[dict], blocks: list[dict]) -> None: | |
| """R21: a header/footer block with only an image is a Picture. R24: a numeric Section is Text. | |
| R23: a run of 3+ short stacked Section blocks (TOC, sidebar) is Text.""" | |
| for it, b in zip(items, blocks): | |
| if it["label"] in ("Page-header", "Page-footer") and not it["text"] and b["tag"].find("img"): | |
| it["label"], it["type"] = "Picture", "image" | |
| if it["label"] == "Section-header" and _KPI.match(it["text"] or "x"): | |
| it["label"] = "Text" | |
| secs = [i for i, it in enumerate(items) if it["label"] == "Section-header" and len(it["text"].split()) <= 6] | |
| run: list[int] = [] | |
| for i in secs + [None]: | |
| if i is not None and run and i == run[-1] + 1 and abs(items[i]["x"] - items[run[-1]]["x"]) < 0.03: | |
| run.append(i) | |
| continue | |
| if len(run) >= 3: | |
| for j in run: | |
| items[j]["label"] = "Text" | |
| run = [i] if i is not None else [] | |
| def to_layout_items(html: str) -> list[dict]: | |
| """[{label, x, y, w, h (0-1), text, type}] for ParseBench layout_pages.""" | |
| items = [] | |
| for b in parse_blocks(html): | |
| if len(b["bbox"]) != 4: | |
| continue | |
| x0, y0, x1, y1 = (v / 1000.0 for v in b["bbox"]) | |
| lab = b["label"] | |
| typ = "table" if lab == "Table" else ("image" if lab in IMAGE_LABELS else "text") | |
| parts = _split_list(b, x0, y0, x1, y1) if lab in LIST_LABELS else [] | |
| if lab == "Text": | |
| parts = _split_br(b, x0, y0, x1, y1) | |
| for ya, yb, t in parts: | |
| items.append({"label": LABEL_MAP.get(lab, "Text"), "x": x0, "y": ya, | |
| "w": max(0.0, x1 - x0), "h": max(0.0, yb - ya), "text": t, "type": typ}) | |
| if parts: | |
| continue | |
| items.append({ | |
| "label": LABEL_MAP.get(lab, "Text"), | |
| "x": x0, "y": y0, "w": max(0.0, x1 - x0), "h": max(0.0, y1 - y0), | |
| "text": plain_text(str(b["tag"])), "type": typ, "_block": b, | |
| }) | |
| if rubric_relabel: | |
| _rubric_relabel(items, [it.get("_block") or {"tag": BeautifulSoup("", "html.parser")} for it in items]) | |
| for it in items: | |
| it.pop("_block", None) | |
| if grid_candidates: | |
| items = items + [g for it in items if it["label"] in ("Text", "Picture", "Table") for g in _grid_boxes(it)] | |
| if dup_text_section: | |
| flip = {"Text": "Section-header", "Section-header": "Text"} | |
| items = items + [{**it, "label": flip[it["label"]]} for it in items if it["label"] in flip] | |
| if extra_line_boxes: | |
| items = items + [s for it in items if it["label"] == "Text" for s in _line_strips(it)] | |
| return items | |