Image-Text-to-Text
Transformers
Safetensors
nemotron_parse
text-generation
VLM
OCR
Parse
conversational
custom_code
Eval Results
Instructions to use nvidia/Nemotron-3.5-Parse with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use nvidia/Nemotron-3.5-Parse with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("image-text-to-text", model="nvidia/Nemotron-3.5-Parse", trust_remote_code=True) 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 AutoModelForCausalLM model = AutoModelForCausalLM.from_pretrained("nvidia/Nemotron-3.5-Parse", trust_remote_code=True, device_map="auto") - Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- vLLM
How to use nvidia/Nemotron-3.5-Parse with vLLM:
Install from pip and serve model
# Install vLLM from pip: pip install vllm # Start the vLLM server: vllm serve "nvidia/Nemotron-3.5-Parse" # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:8000/v1/chat/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "nvidia/Nemotron-3.5-Parse", "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/nvidia/Nemotron-3.5-Parse
- SGLang
How to use nvidia/Nemotron-3.5-Parse 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 "nvidia/Nemotron-3.5-Parse" \ --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": "nvidia/Nemotron-3.5-Parse", "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 "nvidia/Nemotron-3.5-Parse" \ --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": "nvidia/Nemotron-3.5-Parse", "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 nvidia/Nemotron-3.5-Parse with Docker Model Runner:
docker model run hf.co/nvidia/Nemotron-3.5-Parse
Fix malformed block parsing in reference pipelines
Browse filesMalformed or truncated blocks could contaminate following tables, and mismatched replacement indices could raise StopIteration. Recover completed text independently of bad coordinates, preserve raw fragments, and isolate malformed pages and crops. Validated with 10 regression tests, a matched 2,078-page replay, and fresh single/double/triple inference on 16 pages using one H100.
- reference/parsebench/pipeline/PACKAGE_MANIFEST.json +26 -10
- reference/parsebench/pipeline/engine/blocks.py +108 -0
- reference/parsebench/pipeline/engine/geometry.py +1 -5
- reference/parsebench/pipeline/engine/markdown.py +13 -8
- reference/parsebench/pipeline/engine/merge.py +5 -6
- reference/parsebench/pipeline/engine/policies.py +7 -4
- reference/parsebench/pipeline/parse_results.py +92 -0
- reference/parsebench/pipeline/postprocess.py +2 -12
- reference/parsebench/pipeline/tests/malformed_coordinate_regression.json +6 -0
- reference/parsebench/pipeline/tests/test_parser_general.py +97 -0
reference/parsebench/pipeline/PACKAGE_MANIFEST.json
CHANGED
|
@@ -74,28 +74,28 @@
|
|
| 74 |
"sha256": "3d92146e6c713b9de67cb71e92fe254a1344d9054c3925a85cb778ec68c0d075"
|
| 75 |
},
|
| 76 |
"engine/geometry.py": {
|
| 77 |
-
"bytes":
|
| 78 |
-
"sha256": "
|
| 79 |
},
|
| 80 |
"engine/header_inference_probe.py": {
|
| 81 |
"bytes": 2498,
|
| 82 |
"sha256": "02950f1aec5e2dc88a96487bd9124c97e5ce2842fbcfc5264ac2d5f697865722"
|
| 83 |
},
|
| 84 |
"engine/markdown.py": {
|
| 85 |
-
"bytes":
|
| 86 |
-
"sha256": "
|
| 87 |
},
|
| 88 |
"engine/merge.py": {
|
| 89 |
-
"bytes":
|
| 90 |
-
"sha256": "
|
| 91 |
},
|
| 92 |
"engine/navit_geometry.py": {
|
| 93 |
"bytes": 13519,
|
| 94 |
"sha256": "469c05b5afa19ed5669c8866185a1a149846601c6e45f6c002d55ae80cb6424b"
|
| 95 |
},
|
| 96 |
"engine/policies.py": {
|
| 97 |
-
"bytes":
|
| 98 |
-
"sha256": "
|
| 99 |
},
|
| 100 |
"engine/regions.py": {
|
| 101 |
"bytes": 866,
|
|
@@ -114,8 +114,8 @@
|
|
| 114 |
"sha256": "525664ca0359029efe8c010ea8a04b1914fb42e7286e63f0cbe30c9b026fac84"
|
| 115 |
},
|
| 116 |
"postprocess.py": {
|
| 117 |
-
"bytes":
|
| 118 |
-
"sha256": "
|
| 119 |
},
|
| 120 |
"postprocessing_checks.json": {
|
| 121 |
"bytes": 3972,
|
|
@@ -156,6 +156,22 @@
|
|
| 156 |
"validation_metrics.json": {
|
| 157 |
"bytes": 14980,
|
| 158 |
"sha256": "1f69fb9cc9860136e93c693f08d0d74f079ce550dc2a8cfbb82be0b4ead38554"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
}
|
| 160 |
}
|
| 161 |
}
|
|
|
|
| 74 |
"sha256": "3d92146e6c713b9de67cb71e92fe254a1344d9054c3925a85cb778ec68c0d075"
|
| 75 |
},
|
| 76 |
"engine/geometry.py": {
|
| 77 |
+
"bytes": 12991,
|
| 78 |
+
"sha256": "ea542d276bf66ed2fb4cc556e0154ac7de95f90d27701cb79b973b9d3d154a92"
|
| 79 |
},
|
| 80 |
"engine/header_inference_probe.py": {
|
| 81 |
"bytes": 2498,
|
| 82 |
"sha256": "02950f1aec5e2dc88a96487bd9124c97e5ce2842fbcfc5264ac2d5f697865722"
|
| 83 |
},
|
| 84 |
"engine/markdown.py": {
|
| 85 |
+
"bytes": 3834,
|
| 86 |
+
"sha256": "308ac27ec325cf7d5f4b5ee11341316e403eabb0773d73e97330e268f4573a5c"
|
| 87 |
},
|
| 88 |
"engine/merge.py": {
|
| 89 |
+
"bytes": 20206,
|
| 90 |
+
"sha256": "e47d8a21cb53f763d7d26cd27496be07e5e9e4ee346c5915037ef066ff3891d8"
|
| 91 |
},
|
| 92 |
"engine/navit_geometry.py": {
|
| 93 |
"bytes": 13519,
|
| 94 |
"sha256": "469c05b5afa19ed5669c8866185a1a149846601c6e45f6c002d55ae80cb6424b"
|
| 95 |
},
|
| 96 |
"engine/policies.py": {
|
| 97 |
+
"bytes": 9212,
|
| 98 |
+
"sha256": "0115543cfa9656520a858030022820998b0d18cdf2883006fe14c97da27039bb"
|
| 99 |
},
|
| 100 |
"engine/regions.py": {
|
| 101 |
"bytes": 866,
|
|
|
|
| 114 |
"sha256": "525664ca0359029efe8c010ea8a04b1914fb42e7286e63f0cbe30c9b026fac84"
|
| 115 |
},
|
| 116 |
"postprocess.py": {
|
| 117 |
+
"bytes": 3512,
|
| 118 |
+
"sha256": "3529d8d38163c2ddb1838fcbe6affb869034d2b7fd3e1ccad8cdb51788f3b5b1"
|
| 119 |
},
|
| 120 |
"postprocessing_checks.json": {
|
| 121 |
"bytes": 3972,
|
|
|
|
| 156 |
"validation_metrics.json": {
|
| 157 |
"bytes": 14980,
|
| 158 |
"sha256": "1f69fb9cc9860136e93c693f08d0d74f079ce550dc2a8cfbb82be0b4ead38554"
|
| 159 |
+
},
|
| 160 |
+
"parse_results.py": {
|
| 161 |
+
"bytes": 4585,
|
| 162 |
+
"sha256": "b5b996f374c701b3dd805fed486b00b64ada63b5816f4201380dee4e7f6e2e8f"
|
| 163 |
+
},
|
| 164 |
+
"tests/malformed_coordinate_regression.json": {
|
| 165 |
+
"bytes": 832,
|
| 166 |
+
"sha256": "46fdb8cdb0e091f1f3aa07b399c7bcb322e163340f0391c82c150055f78c5a97"
|
| 167 |
+
},
|
| 168 |
+
"tests/test_parser_general.py": {
|
| 169 |
+
"bytes": 5380,
|
| 170 |
+
"sha256": "82bc9366d634405402319c75eacb34468d3a4a05ce7a84e8dccd33962116d12b"
|
| 171 |
+
},
|
| 172 |
+
"engine/blocks.py": {
|
| 173 |
+
"bytes": 4547,
|
| 174 |
+
"sha256": "de94142d5442205c8a2c728137e1247de3660c04a73e393bda7b3f45975d993e"
|
| 175 |
}
|
| 176 |
}
|
| 177 |
}
|
reference/parsebench/pipeline/engine/blocks.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
| 2 |
+
# SPDX-License-Identifier: OpenMDW-1.1
|
| 3 |
+
"""Shared block grammar: an invalid block cannot consume the following block."""
|
| 4 |
+
import re
|
| 5 |
+
|
| 6 |
+
# A class terminator ends a block even when its coordinates are malformed.
|
| 7 |
+
BODY = r'((?:(?!<class_|<x_[^<>]*><y_[^<>]*>).)*?)'
|
| 8 |
+
NUMBER = r'(\d+(?:\.\d+)?)'
|
| 9 |
+
BLOCK_RE = re.compile(
|
| 10 |
+
r'<x_' + NUMBER + r'><y_' + NUMBER + r'>' + BODY +
|
| 11 |
+
r'<x_' + NUMBER + r'><y_' + NUMBER + r'><class_([^>]+)>', re.DOTALL)
|
| 12 |
+
TEXT_BLOCK_RE = re.compile(
|
| 13 |
+
r'<x_([^>]*)><y_([^>]*)>' + BODY +
|
| 14 |
+
r'<x_([^>]*)><y_([^>]*)><class_([^>]+)>', re.DOTALL)
|
| 15 |
+
|
| 16 |
+
CLASS_RE = re.compile(r'<class_([^>]*)>')
|
| 17 |
+
OPEN_PAIR_RE = re.compile(r'^\s*<x_[^<>]*><y_[^<>]*>')
|
| 18 |
+
CLOSE_PAIR_RE = re.compile(r'<x_[^<>]*><y_[^<>]*>\s*$')
|
| 19 |
+
PAIR_RE = re.compile(r'<x_[^<>]*><y_[^<>]*>')
|
| 20 |
+
COORDINATE_RE = re.compile(r'<[xy]_[^<>]*(?:>|(?=<)|$)')
|
| 21 |
+
CONTROL_RE = re.compile(r'</?s>|<pad>|<unk>|<image>|<tbc>|<\|[^>]+\|>|<(?:predict|output)_[^>]*>')
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def visible_text(raw):
|
| 25 |
+
"""Remove serialization controls, including incomplete coordinate tokens."""
|
| 26 |
+
return CONTROL_RE.sub('', COORDINATE_RE.sub('', raw)).strip()
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def text_blocks(raw):
|
| 30 |
+
"""Read text independently of whether coordinates are valid numerical values."""
|
| 31 |
+
cursor = 0
|
| 32 |
+
for match in CLASS_RE.finditer(raw or ''):
|
| 33 |
+
segment = raw[cursor:match.start()]
|
| 34 |
+
pairs = list(PAIR_RE.finditer(segment))
|
| 35 |
+
opening = pairs[-2] if len(pairs) >= 2 else None
|
| 36 |
+
closing = pairs[-1] if pairs and not segment[pairs[-1].end():].strip() else None
|
| 37 |
+
# A dangling preceding block must not become the body of a later,
|
| 38 |
+
# completed block (for example after inserting a valid crop result).
|
| 39 |
+
body = (segment[opening.end():closing.start()].strip()
|
| 40 |
+
if opening and closing and opening.end() <= closing.start()
|
| 41 |
+
else visible_text(segment))
|
| 42 |
+
if body:
|
| 43 |
+
yield match.group(1), body
|
| 44 |
+
cursor = match.end()
|
| 45 |
+
raw_tail = (raw or '')[cursor:]
|
| 46 |
+
tail = visible_text(raw_tail)
|
| 47 |
+
# An unfinished serialized block is retained in raw output, not presented as
|
| 48 |
+
# a successfully decoded text/chart/table block.
|
| 49 |
+
if tail and cursor == 0 and not any(token in raw_tail for token in ['<x_', '<y_']):
|
| 50 |
+
yield 'Text', tail
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def has_unparsed_content(raw):
|
| 54 |
+
cursor = 0
|
| 55 |
+
for match in BLOCK_RE.finditer(raw or ''):
|
| 56 |
+
if visible_text(raw[cursor:match.start()]) or COORDINATE_RE.search(raw[cursor:match.start()]):
|
| 57 |
+
return True
|
| 58 |
+
cursor = match.end()
|
| 59 |
+
gap=(raw or '')[cursor:]
|
| 60 |
+
return bool(visible_text(gap) or COORDINATE_RE.search(gap))
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def insert_preserving_unparsed(raw, direct_blocks, merged_blocks):
|
| 64 |
+
"""Insert additions around validated blocks without discarding malformed text."""
|
| 65 |
+
matches = list(BLOCK_RE.finditer(raw))
|
| 66 |
+
positions = {id(block):match for block,match in zip(direct_blocks,matches)}
|
| 67 |
+
pieces, pending, cursor = [], [], 0
|
| 68 |
+
for block in merged_blocks:
|
| 69 |
+
match = positions.get(id(block))
|
| 70 |
+
if match is None:
|
| 71 |
+
pending.append(block.raw)
|
| 72 |
+
continue
|
| 73 |
+
pieces.append(raw[cursor:match.start()])
|
| 74 |
+
if pending:
|
| 75 |
+
pieces.append('\n\n'.join(pending)+'\n\n')
|
| 76 |
+
pending = []
|
| 77 |
+
pieces.append(raw[match.start():match.end()])
|
| 78 |
+
cursor = match.end()
|
| 79 |
+
pieces.append(raw[cursor:])
|
| 80 |
+
if pending:
|
| 81 |
+
pieces.append('\n\n'+'\n\n'.join(pending))
|
| 82 |
+
return ''.join(pieces)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def incomplete_tail(raw):
|
| 86 |
+
matches=list(CLASS_RE.finditer(raw or ''))
|
| 87 |
+
tail=(raw or '')[matches[-1].end() if matches else 0:]
|
| 88 |
+
return bool(tail.strip()) and (bool(matches) or any(token in tail for token in ['<x_', '<y_']))
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def canonical_geometry(raw):
|
| 92 |
+
"""Remove bad coordinate controls only from closed, unvalidated blocks.
|
| 93 |
+
|
| 94 |
+
Unfinished tails remain verbatim in raw output and receive a diagnostic.
|
| 95 |
+
"""
|
| 96 |
+
import math
|
| 97 |
+
terminals=list(CLASS_RE.finditer(raw or ''))
|
| 98 |
+
boundary=terminals[-1].end() if terminals else 0
|
| 99 |
+
closed=(raw or '')[:boundary]
|
| 100 |
+
pieces,cursor=[],0
|
| 101 |
+
for match in BLOCK_RE.finditer(closed):
|
| 102 |
+
pieces.append(COORDINATE_RE.sub('',closed[cursor:match.start()]))
|
| 103 |
+
coords=[match[i] for i in [1,2,4,5]]
|
| 104 |
+
pieces.append(match[0] if all(math.isfinite(float(v)) for v in coords) else COORDINATE_RE.sub('',match[0]))
|
| 105 |
+
cursor=match.end()
|
| 106 |
+
pieces.append(COORDINATE_RE.sub('',closed[cursor:]))
|
| 107 |
+
pieces.append((raw or '')[boundary:])
|
| 108 |
+
return ''.join(pieces)
|
reference/parsebench/pipeline/engine/geometry.py
CHANGED
|
@@ -60,11 +60,7 @@ import numpy as np
|
|
| 60 |
from PIL import Image
|
| 61 |
|
| 62 |
|
| 63 |
-
|
| 64 |
-
r"<x_(\d+(?:\.\d+)?)><y_(\d+(?:\.\d+)?)>(.*?)"
|
| 65 |
-
r"<x_(\d+(?:\.\d+)?)><y_(\d+(?:\.\d+)?)><class_([^>]+)>",
|
| 66 |
-
re.MULTILINE | re.DOTALL,
|
| 67 |
-
)
|
| 68 |
|
| 69 |
|
| 70 |
@dataclass(frozen=True)
|
|
|
|
| 60 |
from PIL import Image
|
| 61 |
|
| 62 |
|
| 63 |
+
from .blocks import BLOCK_RE
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
|
| 65 |
|
| 66 |
@dataclass(frozen=True)
|
reference/parsebench/pipeline/engine/markdown.py
CHANGED
|
@@ -61,10 +61,7 @@ TEXT_BEARING_CLASSES = {
|
|
| 61 |
}
|
| 62 |
|
| 63 |
|
| 64 |
-
|
| 65 |
-
r"<x_(\d+(?:\.\d+)?)><y_(\d+(?:\.\d+)?)>(.*?)<x_(\d+(?:\.\d+)?)><y_(\d+(?:\.\d+)?)><class_([^>]+)>",
|
| 66 |
-
re.DOTALL,
|
| 67 |
-
)
|
| 68 |
|
| 69 |
|
| 70 |
def extract_classes_bboxes(text: str) -> tuple[list[str], list[tuple[float, float, float, float]], list[str]]:
|
|
@@ -107,9 +104,20 @@ def _simple_latex_table_to_html(text: str) -> str:
|
|
| 107 |
return pattern.sub(repl, text)
|
| 108 |
|
| 109 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
def mmd_to_markdown(prediction: str, text_transform=None) -> str:
|
| 111 |
-
|
|
|
|
|
|
|
| 112 |
if not texts:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
cleaned = re.sub(r"<x_[^>]+>|<y_[^>]+>|<class_[^>]+>", "", prediction or "")
|
| 114 |
return convert_latex_tables_in_markdown(cleaned.replace("<tbc>", "").strip())
|
| 115 |
|
|
@@ -125,9 +133,6 @@ def mmd_to_markdown(prediction: str, text_transform=None) -> str:
|
|
| 125 |
text = f"# {text}"
|
| 126 |
elif cls == "Section-header" and not text.startswith("#"):
|
| 127 |
text = f"## {text}"
|
| 128 |
-
elif cls not in TEXT_BEARING_CLASSES and cls not in {"Formula", "Picture"}:
|
| 129 |
-
continue
|
| 130 |
blocks.append(text)
|
| 131 |
return convert_latex_tables_in_markdown("\n\n".join(blocks).strip())
|
| 132 |
|
| 133 |
-
|
|
|
|
| 61 |
}
|
| 62 |
|
| 63 |
|
| 64 |
+
from .blocks import BLOCK_RE as _RE_EXTRACT_CLASS_BBOX, text_blocks
|
|
|
|
|
|
|
|
|
|
| 65 |
|
| 66 |
|
| 67 |
def extract_classes_bboxes(text: str) -> tuple[list[str], list[tuple[float, float, float, float]], list[str]]:
|
|
|
|
| 104 |
return pattern.sub(repl, text)
|
| 105 |
|
| 106 |
|
| 107 |
+
# Text remains usable when a coordinate token is malformed. Keep numerical
|
| 108 |
+
# coordinate validation in extract_classes_bboxes for callers that need geometry.
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
|
| 112 |
def mmd_to_markdown(prediction: str, text_transform=None) -> str:
|
| 113 |
+
parsed = list(text_blocks(prediction or ""))
|
| 114 |
+
classes = [cls for cls, _ in parsed]
|
| 115 |
+
texts = [text for _, text in parsed]
|
| 116 |
if not texts:
|
| 117 |
+
# No class-terminated block was completed. Keep the original response
|
| 118 |
+
# available to callers instead of presenting a partial table as parsed.
|
| 119 |
+
if any(token in (prediction or "") for token in ['<x_', '<y_']):
|
| 120 |
+
return ""
|
| 121 |
cleaned = re.sub(r"<x_[^>]+>|<y_[^>]+>|<class_[^>]+>", "", prediction or "")
|
| 122 |
return convert_latex_tables_in_markdown(cleaned.replace("<tbc>", "").strip())
|
| 123 |
|
|
|
|
| 133 |
text = f"# {text}"
|
| 134 |
elif cls == "Section-header" and not text.startswith("#"):
|
| 135 |
text = f"## {text}"
|
|
|
|
|
|
|
| 136 |
blocks.append(text)
|
| 137 |
return convert_latex_tables_in_markdown("\n\n".join(blocks).strip())
|
| 138 |
|
|
|
reference/parsebench/pipeline/engine/merge.py
CHANGED
|
@@ -30,11 +30,7 @@ from pathlib import Path
|
|
| 30 |
from typing import Any
|
| 31 |
|
| 32 |
|
| 33 |
-
BLOCK_RE
|
| 34 |
-
r"<x_(\d+(?:\.\d+)?)><y_(\d+(?:\.\d+)?)>(.*?)"
|
| 35 |
-
r"<x_(\d+(?:\.\d+)?)><y_(\d+(?:\.\d+)?)><class_([^>]+)>",
|
| 36 |
-
re.DOTALL,
|
| 37 |
-
)
|
| 38 |
|
| 39 |
|
| 40 |
TOKEN_RE = re.compile(r"</?s>|<pad>|<unk>|<image>|<\|[^>]+\|>")
|
|
@@ -566,7 +562,10 @@ def build_direct_plus_ordered_structural_addonly(
|
|
| 566 |
added.append(block)
|
| 567 |
merged_blocks = insert_additions_preserving_direct_order(direct_blocks, added)
|
| 568 |
row = clone_prediction(label, direct, source_name=f"direct-plus-{args.structural_source}-ordered-structural-addonly")
|
| 569 |
-
|
|
|
|
|
|
|
|
|
|
| 570 |
row["hybrid_kept_direct_blocks"] = len(direct_blocks)
|
| 571 |
row["hybrid_added_structural_blocks"] = len(added)
|
| 572 |
row["hybrid_skipped_duplicate_blocks"] = skipped_duplicates
|
|
|
|
| 30 |
from typing import Any
|
| 31 |
|
| 32 |
|
| 33 |
+
from .blocks import BLOCK_RE, has_unparsed_content, insert_preserving_unparsed
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
|
| 36 |
TOKEN_RE = re.compile(r"</?s>|<pad>|<unk>|<image>|<\|[^>]+\|>")
|
|
|
|
| 562 |
added.append(block)
|
| 563 |
merged_blocks = insert_additions_preserving_direct_order(direct_blocks, added)
|
| 564 |
row = clone_prediction(label, direct, source_name=f"direct-plus-{args.structural_source}-ordered-structural-addonly")
|
| 565 |
+
direct_raw = prediction_text(direct)
|
| 566 |
+
row["raw_out"] = (insert_preserving_unparsed(direct_raw, direct_blocks, merged_blocks)
|
| 567 |
+
if has_unparsed_content(direct_raw)
|
| 568 |
+
else rebuild_blocks(merged_blocks)) or direct_raw or prediction_text(structural)
|
| 569 |
row["hybrid_kept_direct_blocks"] = len(direct_blocks)
|
| 570 |
row["hybrid_added_structural_blocks"] = len(added)
|
| 571 |
row["hybrid_skipped_duplicate_blocks"] = skipped_duplicates
|
reference/parsebench/pipeline/engine/policies.py
CHANGED
|
@@ -168,8 +168,11 @@ def replace_guarded(raw,candidates,merger,similarity=.65,guard='valid',repair=Fa
|
|
| 168 |
for overlap,sim,i,j,body in sorted(pairs,reverse=True):
|
| 169 |
if i in chosen or j in used:continue
|
| 170 |
chosen[i]=body;used.add(j)
|
| 171 |
-
|
|
|
|
|
|
|
| 172 |
def f(m):
|
| 173 |
-
i=
|
| 174 |
-
|
| 175 |
-
|
|
|
|
|
|
| 168 |
for overlap,sim,i,j,body in sorted(pairs,reverse=True):
|
| 169 |
if i in chosen or j in used:continue
|
| 170 |
chosen[i]=body;used.add(j)
|
| 171 |
+
# Match the same coordinate grammar used by parse_blocks. The broader BLOCK
|
| 172 |
+
# pattern also matches malformed coordinates and cannot share positional indices.
|
| 173 |
+
spans={(m.start(),m.end()):i for i,m in enumerate(merger.BLOCK_RE.finditer(raw))}
|
| 174 |
def f(m):
|
| 175 |
+
i=spans[(m.start(),m.end())]
|
| 176 |
+
if i not in chosen:return m[0]
|
| 177 |
+
return raw[m.start():m.start(3)]+chosen[i]+raw[m.end(3):m.end()]
|
| 178 |
+
return merger.BLOCK_RE.sub(f,raw)
|
reference/parsebench/pipeline/parse_results.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
| 2 |
+
# SPDX-License-Identifier: OpenMDW-1.1
|
| 3 |
+
"""Parse available model content and isolate failures; no request or time policies."""
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import html
|
| 7 |
+
import multiprocessing
|
| 8 |
+
from collections import defaultdict
|
| 9 |
+
from concurrent.futures import ProcessPoolExecutor
|
| 10 |
+
|
| 11 |
+
import postprocess as renderer
|
| 12 |
+
from engine import merge
|
| 13 |
+
from engine.blocks import canonical_geometry, has_unparsed_content, incomplete_tail, text_blocks
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def error(stage, exc):
|
| 17 |
+
return {'stage': stage, 'type': type(exc).__name__, 'message': str(exc)}
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def fallback(row, errors):
|
| 21 |
+
raw = row.get('raw_out') or ''
|
| 22 |
+
return {'key': renderer.key(row), 'raw_out': raw,
|
| 23 |
+
'source_raw_out': row.get('source_raw_out', raw),
|
| 24 |
+
'markdown': html.escape(raw), 'coordinate_frame': 'content_normalized',
|
| 25 |
+
'postprocessing_status': 'raw_fallback', 'postprocessing_errors': errors}
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def finalize(task):
|
| 29 |
+
row, candidates, errors = task
|
| 30 |
+
errors = list(errors)
|
| 31 |
+
original = row.get('raw_out') or ''
|
| 32 |
+
if row.get('error'):
|
| 33 |
+
errors.append({'stage': 'fullpage_generation', 'error': row['error']})
|
| 34 |
+
try:
|
| 35 |
+
raw = renderer.repair_empty_prefix(original)
|
| 36 |
+
if has_unparsed_content(raw) and any(t in raw for t in ['<x_', '<y_', '<class_']):
|
| 37 |
+
errors.append({'stage': 'coordinate_validation',
|
| 38 |
+
'type': 'IncompleteBlock' if incomplete_tail(raw) else 'MalformedCoordinates'})
|
| 39 |
+
known = renderer.adapter.TEXT_BEARING_CLASSES | {'Table', 'Formula', 'Picture'}
|
| 40 |
+
unknown = sorted({cls for cls, _ in text_blocks(raw) if cls not in known})
|
| 41 |
+
if unknown:
|
| 42 |
+
errors.append({'stage': 'class_validation', 'type': 'UnknownClasses', 'classes': unknown})
|
| 43 |
+
row = dict(row, raw_out=canonical_geometry(raw))
|
| 44 |
+
if row['raw_out'] != original:
|
| 45 |
+
row['source_raw_out'] = original
|
| 46 |
+
if candidates:
|
| 47 |
+
row = merge.build_direct_plus_ordered_structural_addonly(
|
| 48 |
+
renderer.arguments(), [row], {'direct': merge.index_rows([row])},
|
| 49 |
+
{renderer.key(row): candidates})[0]
|
| 50 |
+
# Insertions can place a formerly unfinished tail before a new
|
| 51 |
+
# closed block. Validate that boundary again before rendering.
|
| 52 |
+
row = dict(row, raw_out=canonical_geometry(row['raw_out']), source_raw_out=original)
|
| 53 |
+
result = renderer.finalize_one((row, candidates))
|
| 54 |
+
result['postprocessing_status'] = 'partial' if errors else ('empty' if not original.strip() else 'ok')
|
| 55 |
+
result['postprocessing_errors'] = errors
|
| 56 |
+
if row.get('source_raw_out') is not None:
|
| 57 |
+
result['source_raw_out'] = row['source_raw_out']
|
| 58 |
+
return result
|
| 59 |
+
except Exception as exc:
|
| 60 |
+
return fallback(dict(row, source_raw_out=original), errors + [error('page_rendering', exc)])
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def process(direct, crop_path=None, workers=8):
|
| 64 |
+
rows = [dict(row, key=renderer.key(row)) for row in direct]
|
| 65 |
+
if len({r['key'] for r in rows}) != len(rows):
|
| 66 |
+
raise ValueError('Duplicate input keys')
|
| 67 |
+
args = renderer.arguments()
|
| 68 |
+
classes = merge.parse_class_set(args.structural_classes)
|
| 69 |
+
labels = merge.parse_label_filter(args.include_detector_labels)
|
| 70 |
+
candidates, errors = defaultdict(list), defaultdict(list)
|
| 71 |
+
for crop in renderer.read_rows(crop_path) if crop_path else []:
|
| 72 |
+
key = str(crop.get('page_key') or '')
|
| 73 |
+
if not key:
|
| 74 |
+
continue
|
| 75 |
+
try:
|
| 76 |
+
if crop.get('error'):
|
| 77 |
+
errors[key].append({'stage': 'crop_generation', 'error': crop['error']})
|
| 78 |
+
continue
|
| 79 |
+
if labels and merge.normalize_detector_label(crop.get('detector_label') or '') not in labels:
|
| 80 |
+
continue
|
| 81 |
+
for block in merge.rebuild_crop_row_blocks(crop, args):
|
| 82 |
+
if block.cls in classes and not (args.min_structural_area > 0 and merge.block_area(block) < args.min_structural_area):
|
| 83 |
+
candidates[key].append(block)
|
| 84 |
+
except Exception as exc:
|
| 85 |
+
errors[key].append(error('crop_reconstruction', exc))
|
| 86 |
+
for blocks in candidates.values():
|
| 87 |
+
blocks.sort(key=merge.reading_order_key)
|
| 88 |
+
tasks = [(row, candidates.get(row['key'], []), errors.get(row['key'], [])) for row in rows]
|
| 89 |
+
if workers == 1:
|
| 90 |
+
return [finalize(task) for task in tasks]
|
| 91 |
+
with ProcessPoolExecutor(max_workers=workers, mp_context=multiprocessing.get_context('fork')) as pool:
|
| 92 |
+
return list(pool.map(finalize, tasks, chunksize=8))
|
reference/parsebench/pipeline/postprocess.py
CHANGED
|
@@ -54,18 +54,8 @@ def finalize_one(task):
|
|
| 54 |
return {'key':k,'raw_out':raw,'markdown':markdown(raw),'coordinate_frame':'content_normalized'}
|
| 55 |
|
| 56 |
def process(direct,crop_path=None,workers=8):
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
if any(x.get('error') for x in direct):raise ValueError('Input contains generation failures')
|
| 60 |
-
args=arguments(crop_path);candidates=merge.crop_element_blocks_by_page(args)
|
| 61 |
-
if candidates:
|
| 62 |
-
indexes={'direct':merge.index_rows(direct)}
|
| 63 |
-
joined=merge.build_direct_plus_ordered_structural_addonly(args,direct,indexes,candidates)
|
| 64 |
-
else:joined=direct
|
| 65 |
-
tasks=[(row,candidates.get(key(row),[])) for row in joined]
|
| 66 |
-
if workers==1:return list(map(finalize_one,tasks))
|
| 67 |
-
with ProcessPoolExecutor(max_workers=workers,mp_context=multiprocessing.get_context('fork')) as pool:
|
| 68 |
-
return list(pool.map(finalize_one,tasks,chunksize=8))
|
| 69 |
|
| 70 |
def main():
|
| 71 |
p=argparse.ArgumentParser(description=__doc__);p.add_argument('--direct',type=Path,required=True)
|
|
|
|
| 54 |
return {'key':k,'raw_out':raw,'markdown':markdown(raw),'coordinate_frame':'content_normalized'}
|
| 55 |
|
| 56 |
def process(direct,crop_path=None,workers=8):
|
| 57 |
+
from parse_results import process as parse_process
|
| 58 |
+
return parse_process(direct,crop_path,workers)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
|
| 60 |
def main():
|
| 61 |
p=argparse.ArgumentParser(description=__doc__);p.add_argument('--direct',type=Path,required=True)
|
reference/parsebench/pipeline/tests/malformed_coordinate_regression.json
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"key": "malformed_footer_fixture",
|
| 4 |
+
"raw_out": "<x_0.1><y_0.1>Text 0<x_0.8><y_0.8><class_Text><x_0.1><y_0.1>Text 1<x_0.8><y_0.8><class_Text><x_0.1><y_0.1>Text 2<x_0.8><y_0.8><class_Text><x_0.1><y_0.1>Text 3<x_0.8><y_0.8><class_Text><x_0.1><y_0.1>Text 4<x_0.8><y_0.8><class_Text><x_0.1><y_0.1>Text 5<x_0.8><y_0.8><class_Text><x_0.1><y_0.1>Text 6<x_0.8><y_0.8><class_Text><x_0.1><y_0.1>Text 7<x_0.8><y_0.8><class_Text><x_0.1><y_0.1>Text 8<x_0.8><y_0.8><class_Text><x_0.1><y_0.1>Text 9<x_0.8><y_0.8><class_Text><x_0.1><y_0.1>Text 10<x_0.8><y_0.8><class_Text><x_0.1><y_0.1>Text 11<x_0.8><y_0.8><class_Text><x_0.1><y_0.1>Text 12<x_0.8><y_0.8><class_Text><x_0.1><y_0.1>Text 13<x_0.8><y_0.8><class_Text><x_0.1><y_0.1>Text 14<x_0.8><y_0.8><class_Text><x_0.1934><y_0.925>Footer text<x_0.org><y_0.9641><class_Page-footer>"
|
| 5 |
+
}
|
| 6 |
+
]
|
reference/parsebench/pipeline/tests/test_parser_general.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
| 2 |
+
# SPDX-License-Identifier: OpenMDW-1.1
|
| 3 |
+
import json
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
import sys
|
| 6 |
+
import tempfile
|
| 7 |
+
import unittest
|
| 8 |
+
from unittest.mock import patch
|
| 9 |
+
|
| 10 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 11 |
+
import postprocess
|
| 12 |
+
from engine import merge, policies
|
| 13 |
+
import parse_results
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class ParserFixes(unittest.TestCase):
|
| 17 |
+
def test_observed_malformed_footer_retains_text_without_invented_box(self):
|
| 18 |
+
path = Path(__file__).with_name('malformed_coordinate_regression.json')
|
| 19 |
+
for row in json.loads(path.read_text()):
|
| 20 |
+
result = postprocess.process([row], workers=1)[0]
|
| 21 |
+
self.assertEqual(result['source_raw_out'], row['raw_out'])
|
| 22 |
+
self.assertEqual(result['postprocessing_status'], 'partial')
|
| 23 |
+
self.assertIn('Footer text', result['markdown'])
|
| 24 |
+
self.assertEqual(len(merge.parse_blocks(result['raw_out'])), 15)
|
| 25 |
+
|
| 26 |
+
def test_malformed_block_does_not_shift_table_replacement(self):
|
| 27 |
+
malformed = '<x_0.1><y_0.1>footer text<x_0.org><y_0.2><class_Page-footer>'
|
| 28 |
+
valid = merge.make_block((.1, .2, .8, .9), 'Table', 'old table')
|
| 29 |
+
candidate = merge.parse_blocks(merge.make_block((.1, .2, .8, .9), 'Table', 'replacement table'))[0]
|
| 30 |
+
result = policies.replace_guarded(malformed+valid, [candidate], merge, similarity=0)
|
| 31 |
+
self.assertTrue(result.startswith(malformed))
|
| 32 |
+
self.assertIn('replacement table', result)
|
| 33 |
+
|
| 34 |
+
def test_orphan_coordinates_do_not_swallow_next_valid_block(self):
|
| 35 |
+
raw = '<x_0.1><y_0.1>broken prefix' + merge.make_block((.2, .2, .8, .3), 'Title', 'A valid title')
|
| 36 |
+
blocks = merge.parse_blocks(raw)
|
| 37 |
+
self.assertEqual(len(blocks), 1)
|
| 38 |
+
self.assertEqual(blocks[0].body, 'A valid title')
|
| 39 |
+
|
| 40 |
+
def test_bad_page_keeps_other_page_and_original_output(self):
|
| 41 |
+
original = postprocess.finalize_one
|
| 42 |
+
def faulty(task):
|
| 43 |
+
if task[0]['key'] == 'bad':
|
| 44 |
+
raise ValueError('invalid table')
|
| 45 |
+
return original(task)
|
| 46 |
+
with patch.object(postprocess, 'finalize_one', side_effect=faulty):
|
| 47 |
+
rows = postprocess.process([{'key': 'good', 'raw_out': 'readable'}, {'key': 'bad', 'raw_out': 'original text'}], workers=1)
|
| 48 |
+
self.assertEqual(rows[0]['markdown'], 'readable')
|
| 49 |
+
self.assertEqual(rows[1]['postprocessing_status'], 'raw_fallback')
|
| 50 |
+
self.assertEqual(rows[1]['source_raw_out'], 'original text')
|
| 51 |
+
|
| 52 |
+
def test_bad_crop_preserves_full_page(self):
|
| 53 |
+
with tempfile.TemporaryDirectory() as tmp:
|
| 54 |
+
p = Path(tmp)/'crops.jsonl'
|
| 55 |
+
p.write_text(json.dumps({'page_key': 'page', 'detector_label': 'table', 'raw_out': 'bad crop', 'detector_bbox_norm': ['invalid']})+'\n')
|
| 56 |
+
row = postprocess.process([{'key': 'page', 'raw_out': 'full page survives'}], p, workers=1)[0]
|
| 57 |
+
self.assertEqual(row['markdown'], 'full page survives')
|
| 58 |
+
self.assertEqual(row['postprocessing_status'], 'partial')
|
| 59 |
+
|
| 60 |
+
def test_unfinished_chart_not_claimed_as_complete(self):
|
| 61 |
+
raw = merge.make_block((.1, .1, .8, .2), 'Text', 'finished text') + '\n<x_0.1><y_0.3><chart type="bar">unfinished data'
|
| 62 |
+
row = postprocess.process([{'key': 'page', 'raw_out': raw}], workers=1)[0]
|
| 63 |
+
self.assertEqual(row['postprocessing_status'], 'partial')
|
| 64 |
+
self.assertIn('finished text', row['markdown'])
|
| 65 |
+
self.assertEqual(row['raw_out'], raw)
|
| 66 |
+
|
| 67 |
+
def test_unfinished_table_cannot_contaminate_next_completed_table(self):
|
| 68 |
+
unfinished = '<x_0.1><y_0.1>\\begin{tabular}{cc}\nunfinished & data'
|
| 69 |
+
complete = merge.make_block((.2, .3, .8, .9), 'Table', '\\begin{tabular}{cc}\nleft & right\\\\\n\\end{tabular}')
|
| 70 |
+
from engine.blocks import text_blocks
|
| 71 |
+
self.assertEqual(list(text_blocks(unfinished+complete)), [('Table', '\\begin{tabular}{cc}\nleft & right\\\\\n\\end{tabular}')])
|
| 72 |
+
result = postprocess.process([{'key': 'page', 'raw_out': unfinished+complete}], workers=1)[0]
|
| 73 |
+
self.assertNotIn('unfinished', result['markdown'])
|
| 74 |
+
self.assertIn('left', result['markdown'])
|
| 75 |
+
self.assertIn('unfinished', result['source_raw_out'])
|
| 76 |
+
|
| 77 |
+
def test_unterminated_trailing_fragment_is_retained_only_in_raw(self):
|
| 78 |
+
raw = merge.make_block((.1, .1, .8, .2), 'Text', 'completed text') + '\nunfinished trailing text'
|
| 79 |
+
result = postprocess.process([{'key': 'page', 'raw_out': raw}], workers=1)[0]
|
| 80 |
+
self.assertEqual(result['markdown'], 'completed text')
|
| 81 |
+
self.assertIn('unfinished trailing text', result['raw_out'])
|
| 82 |
+
self.assertEqual(result['postprocessing_status'], 'partial')
|
| 83 |
+
|
| 84 |
+
def test_plain_input_without_serialized_blocks_still_works(self):
|
| 85 |
+
result = postprocess.process([{'key': 'page', 'raw_out': 'ordinary unstructured text'}], workers=1)[0]
|
| 86 |
+
self.assertEqual(result['markdown'], 'ordinary unstructured text')
|
| 87 |
+
|
| 88 |
+
def test_unfinished_serialized_table_only_not_reported_as_parsed(self):
|
| 89 |
+
raw = '<x_0.1><y_0.1>\\begin{tabular}{cc}\nunfinished & data'
|
| 90 |
+
result = postprocess.process([{'key': 'page', 'raw_out': raw}], workers=1)[0]
|
| 91 |
+
self.assertEqual(result['markdown'], '')
|
| 92 |
+
self.assertEqual(result['raw_out'], raw)
|
| 93 |
+
self.assertEqual(result['postprocessing_status'], 'partial')
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
if __name__ == '__main__':
|
| 97 |
+
unittest.main()
|