emelryan commited on
Commit
e7ea792
·
verified ·
1 Parent(s): 05c8230

Fix malformed block parsing in reference pipelines

Browse files

Malformed 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 CHANGED
@@ -74,28 +74,28 @@
74
  "sha256": "3d92146e6c713b9de67cb71e92fe254a1344d9054c3925a85cb778ec68c0d075"
75
  },
76
  "engine/geometry.py": {
77
- "bytes": 13130,
78
- "sha256": "76671df686b574a827205acb757eecf702e970470ffa15f81b3129cbcd95e242"
79
  },
80
  "engine/header_inference_probe.py": {
81
  "bytes": 2498,
82
  "sha256": "02950f1aec5e2dc88a96487bd9124c97e5ce2842fbcfc5264ac2d5f697865722"
83
  },
84
  "engine/markdown.py": {
85
- "bytes": 3557,
86
- "sha256": "fc57566255ac632b24825973d9533a13fd717dad4e38d096098bac33ecc79957"
87
  },
88
  "engine/merge.py": {
89
- "bytes": 20084,
90
- "sha256": "b1c2e749e8f772b716c5a8a2ebeba7348474eb9368b00d37f1c8987e26d24e4e"
91
  },
92
  "engine/navit_geometry.py": {
93
  "bytes": 13519,
94
  "sha256": "469c05b5afa19ed5669c8866185a1a149846601c6e45f6c002d55ae80cb6424b"
95
  },
96
  "engine/policies.py": {
97
- "bytes": 8930,
98
- "sha256": "4c1f78be4d8061e4224fb59c3dc5342dfc21d9d37826814ea3573f339777aebd"
99
  },
100
  "engine/regions.py": {
101
  "bytes": 866,
@@ -114,8 +114,8 @@
114
  "sha256": "525664ca0359029efe8c010ea8a04b1914fb42e7286e63f0cbe30c9b026fac84"
115
  },
116
  "postprocess.py": {
117
- "bytes": 4211,
118
- "sha256": "576db3eb97c5e43ebbb1b5cdd11aef212f7728fca0b4bfa20fc4d2f309e8eb5f"
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
- BLOCK_RE = re.compile(
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
- _RE_EXTRACT_CLASS_BBOX = re.compile(
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
- classes, _boxes, texts = extract_classes_bboxes(prediction or "")
 
 
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 = re.compile(
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
- row["raw_out"] = rebuild_blocks(merged_blocks) or prediction_text(direct) or prediction_text(structural)
 
 
 
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
- index=iter(range(len(blocks)))
 
 
172
  def f(m):
173
- i=next(index)
174
- return m[1]+chosen[i]+m[3] if i in chosen else m[0]
175
- return BLOCK.sub(f,raw)
 
 
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
- direct=[dict(row,key=key(row)) for row in direct]
58
- if len({x['key'] for x in direct})!=len(direct):raise ValueError('Duplicate input keys')
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()