parse-bench 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. parse_bench/__init__.py +3 -0
  2. parse_bench/analysis/__init__.py +6 -0
  3. parse_bench/analysis/aggregation_report.py +582 -0
  4. parse_bench/analysis/cli.py +472 -0
  5. parse_bench/analysis/comparison.py +382 -0
  6. parse_bench/analysis/comparison_core.py +357 -0
  7. parse_bench/analysis/comparison_report.py +2066 -0
  8. parse_bench/analysis/detailed_report.py +2254 -0
  9. parse_bench/analysis/leaderboard_report.py +852 -0
  10. parse_bench/analysis/metric_definitions.py +771 -0
  11. parse_bench/cli.py +267 -0
  12. parse_bench/data/__init__.py +1 -0
  13. parse_bench/data/cli.py +118 -0
  14. parse_bench/data/download.py +127 -0
  15. parse_bench/evaluation/__init__.py +11 -0
  16. parse_bench/evaluation/cli.py +435 -0
  17. parse_bench/evaluation/evaluators/__init__.py +17 -0
  18. parse_bench/evaluation/evaluators/base.py +34 -0
  19. parse_bench/evaluation/evaluators/extract.py +429 -0
  20. parse_bench/evaluation/evaluators/layoutdet.py +1682 -0
  21. parse_bench/evaluation/evaluators/parse.py +1353 -0
  22. parse_bench/evaluation/evaluators/qa.py +199 -0
  23. parse_bench/evaluation/layout_adapters/__init__.py +21 -0
  24. parse_bench/evaluation/layout_adapters/adapters.py +3180 -0
  25. parse_bench/evaluation/layout_adapters/base.py +105 -0
  26. parse_bench/evaluation/layout_adapters/registry.py +109 -0
  27. parse_bench/evaluation/layout_label_mappers/__init__.py +22 -0
  28. parse_bench/evaluation/layout_label_mappers/base.py +66 -0
  29. parse_bench/evaluation/layout_label_mappers/mappers.py +332 -0
  30. parse_bench/evaluation/layout_label_mappers/projection.py +74 -0
  31. parse_bench/evaluation/layout_label_mappers/registry.py +119 -0
  32. parse_bench/evaluation/metric_aggregation.py +56 -0
  33. parse_bench/evaluation/metrics/__init__.py +5 -0
  34. parse_bench/evaluation/metrics/attribution/__init__.py +35 -0
  35. parse_bench/evaluation/metrics/attribution/constants.py +12 -0
  36. parse_bench/evaluation/metrics/attribution/core.py +1108 -0
  37. parse_bench/evaluation/metrics/attribution/evaluate.py +446 -0
  38. parse_bench/evaluation/metrics/attribution/geometry.py +161 -0
  39. parse_bench/evaluation/metrics/attribution/text_utils.py +233 -0
  40. parse_bench/evaluation/metrics/base.py +33 -0
  41. parse_bench/evaluation/metrics/downstream/__init__.py +0 -0
  42. parse_bench/evaluation/metrics/extract/__init__.py +29 -0
  43. parse_bench/evaluation/metrics/extract/json_subset_match.py +473 -0
  44. parse_bench/evaluation/metrics/extract/json_subset_match_metric.py +81 -0
  45. parse_bench/evaluation/metrics/extract/list_unwrap.py +340 -0
  46. parse_bench/evaluation/metrics/extract/rule_based_metric.py +90 -0
  47. parse_bench/evaluation/metrics/extract/test_rules.py +409 -0
  48. parse_bench/evaluation/metrics/extract/test_types.py +11 -0
  49. parse_bench/evaluation/metrics/field_grounding/__init__.py +21 -0
  50. parse_bench/evaluation/metrics/field_grounding/core.py +437 -0
  51. parse_bench/evaluation/metrics/field_grounding/extract_adapter.py +1224 -0
  52. parse_bench/evaluation/metrics/field_grounding/parse_adapter.py +697 -0
  53. parse_bench/evaluation/metrics/field_grounding/rule_filters.py +19 -0
  54. parse_bench/evaluation/metrics/field_grounding/value_compare.py +190 -0
  55. parse_bench/evaluation/metrics/layoutdet/__init__.py +17 -0
  56. parse_bench/evaluation/metrics/layoutdet/classification_utils.py +300 -0
  57. parse_bench/evaluation/metrics/layoutdet/iou.py +76 -0
  58. parse_bench/evaluation/metrics/parse/__init__.py +5 -0
  59. parse_bench/evaluation/metrics/parse/_vendor_grits_reference.py +531 -0
  60. parse_bench/evaluation/metrics/parse/cross_page_table_consistency.py +165 -0
  61. parse_bench/evaluation/metrics/parse/emphasis_spans.py +242 -0
  62. parse_bench/evaluation/metrics/parse/fast_tree_edit.py +282 -0
  63. parse_bench/evaluation/metrics/parse/grits_metric.py +1125 -0
  64. parse_bench/evaluation/metrics/parse/grits_reference_metric.py +142 -0
  65. parse_bench/evaluation/metrics/parse/header_accuracy_metric.py +1662 -0
  66. parse_bench/evaluation/metrics/parse/llm_normalization/__init__.py +51 -0
  67. parse_bench/evaluation/metrics/parse/llm_normalization/base.py +125 -0
  68. parse_bench/evaluation/metrics/parse/llm_normalization/config.py +44 -0
  69. parse_bench/evaluation/metrics/parse/llm_normalization/postprocess.py +322 -0
  70. parse_bench/evaluation/metrics/parse/llm_normalization/strategy_judge.py +541 -0
  71. parse_bench/evaluation/metrics/parse/mermaid_graph.py +682 -0
  72. parse_bench/evaluation/metrics/parse/rule_based_judge_metric.py +56 -0
  73. parse_bench/evaluation/metrics/parse/rule_based_metric.py +434 -0
  74. parse_bench/evaluation/metrics/parse/rules_bag.py +1161 -0
  75. parse_bench/evaluation/metrics/parse/rules_base.py +751 -0
  76. parse_bench/evaluation/metrics/parse/rules_chart.py +1556 -0
  77. parse_bench/evaluation/metrics/parse/rules_diagram.py +591 -0
  78. parse_bench/evaluation/metrics/parse/rules_form.py +2274 -0
  79. parse_bench/evaluation/metrics/parse/rules_formatting.py +1500 -0
  80. parse_bench/evaluation/metrics/parse/rules_heading.py +228 -0
  81. parse_bench/evaluation/metrics/parse/rules_list.py +226 -0
  82. parse_bench/evaluation/metrics/parse/rules_page_decoration.py +276 -0
  83. parse_bench/evaluation/metrics/parse/rules_table.py +1666 -0
  84. parse_bench/evaluation/metrics/parse/rules_text.py +340 -0
  85. parse_bench/evaluation/metrics/parse/rules_watermark.py +105 -0
  86. parse_bench/evaluation/metrics/parse/structural_consistency_metric.py +251 -0
  87. parse_bench/evaluation/metrics/parse/table_extraction.py +152 -0
  88. parse_bench/evaluation/metrics/parse/table_merging.py +195 -0
  89. parse_bench/evaluation/metrics/parse/table_pairing.py +87 -0
  90. parse_bench/evaluation/metrics/parse/table_parsing.py +955 -0
  91. parse_bench/evaluation/metrics/parse/table_record_match_metric.py +1453 -0
  92. parse_bench/evaluation/metrics/parse/table_splitting.py +301 -0
  93. parse_bench/evaluation/metrics/parse/table_title_stripping.py +530 -0
  94. parse_bench/evaluation/metrics/parse/teds_metric.py +600 -0
  95. parse_bench/evaluation/metrics/parse/test_rules.py +120 -0
  96. parse_bench/evaluation/metrics/parse/test_types.py +103 -0
  97. parse_bench/evaluation/metrics/parse/text_content_projection.py +175 -0
  98. parse_bench/evaluation/metrics/parse/text_similarity_metric.py +61 -0
  99. parse_bench/evaluation/metrics/parse/utils.py +885 -0
  100. parse_bench/evaluation/metrics/qa/__init__.py +5 -0
  101. parse_bench/evaluation/metrics/qa/answer_comparison.py +380 -0
  102. parse_bench/evaluation/qa/__init__.py +5 -0
  103. parse_bench/evaluation/qa/llm_service.py +335 -0
  104. parse_bench/evaluation/reports/__init__.py +8 -0
  105. parse_bench/evaluation/reports/csv.py +64 -0
  106. parse_bench/evaluation/reports/html.py +338 -0
  107. parse_bench/evaluation/reports/markdown.py +98 -0
  108. parse_bench/evaluation/reports/rule_csv.py +22 -0
  109. parse_bench/evaluation/runner.py +1864 -0
  110. parse_bench/evaluation/stats.py +104 -0
  111. parse_bench/extensions.py +72 -0
  112. parse_bench/inference/__init__.py +33 -0
  113. parse_bench/inference/chunkr_layout_extraction.py +160 -0
  114. parse_bench/inference/cli.py +484 -0
  115. parse_bench/inference/layout_extraction.py +422 -0
  116. parse_bench/inference/pipelines/__init__.py +59 -0
  117. parse_bench/inference/pipelines/extract.py +39 -0
  118. parse_bench/inference/pipelines/layout.py +142 -0
  119. parse_bench/inference/pipelines/parse.py +2603 -0
  120. parse_bench/inference/pipelines.py +0 -0
  121. parse_bench/inference/providers/__init__.py +28 -0
  122. parse_bench/inference/providers/base.py +196 -0
  123. parse_bench/inference/providers/cancellation.py +137 -0
  124. parse_bench/inference/providers/extract/__init__.py +22 -0
  125. parse_bench/inference/providers/extract/citations.py +549 -0
  126. parse_bench/inference/providers/extract/extend.py +851 -0
  127. parse_bench/inference/providers/extract/llamaextract_v2_api.py +583 -0
  128. parse_bench/inference/providers/layoutdet/__init__.py +25 -0
  129. parse_bench/inference/providers/layoutdet/adapters.py +946 -0
  130. parse_bench/inference/providers/layoutdet/base.py +203 -0
  131. parse_bench/inference/providers/layoutdet/chandra.py +449 -0
  132. parse_bench/inference/providers/layoutdet/docling.py +125 -0
  133. parse_bench/inference/providers/layoutdet/dots_ocr.py +606 -0
  134. parse_bench/inference/providers/layoutdet/layout_v3.py +137 -0
  135. parse_bench/inference/providers/layoutdet/layout_v3_byoc.py +204 -0
  136. parse_bench/inference/providers/layoutdet/paddle.py +117 -0
  137. parse_bench/inference/providers/layoutdet/qwen3vl.py +360 -0
  138. parse_bench/inference/providers/layoutdet/surya.py +250 -0
  139. parse_bench/inference/providers/layoutdet/yolo.py +109 -0
  140. parse_bench/inference/providers/parse/__init__.py +64 -0
  141. parse_bench/inference/providers/parse/_docling_common.py +233 -0
  142. parse_bench/inference/providers/parse/_layout_utils.py +611 -0
  143. parse_bench/inference/providers/parse/amazon_nova.py +515 -0
  144. parse_bench/inference/providers/parse/anthropic.py +882 -0
  145. parse_bench/inference/providers/parse/azure_document_intelligence.py +700 -0
  146. parse_bench/inference/providers/parse/chandra2.py +633 -0
  147. parse_bench/inference/providers/parse/chunkr.py +268 -0
  148. parse_bench/inference/providers/parse/databricks_ai_parse.py +724 -0
  149. parse_bench/inference/providers/parse/datalab.py +370 -0
  150. parse_bench/inference/providers/parse/deepseekocr2.py +382 -0
  151. parse_bench/inference/providers/parse/docling.py +281 -0
  152. parse_bench/inference/providers/parse/docling_serve.py +289 -0
  153. parse_bench/inference/providers/parse/dots_ocr.py +574 -0
  154. parse_bench/inference/providers/parse/extend_parse.py +710 -0
  155. parse_bench/inference/providers/parse/falconocr.py +436 -0
  156. parse_bench/inference/providers/parse/florin_parser_nano.py +559 -0
  157. parse_bench/inference/providers/parse/gemma4.py +472 -0
  158. parse_bench/inference/providers/parse/glm_zai.py +229 -0
  159. parse_bench/inference/providers/parse/google.py +1125 -0
  160. parse_bench/inference/providers/parse/google_agentic_vision.py +819 -0
  161. parse_bench/inference/providers/parse/google_docai.py +776 -0
  162. parse_bench/inference/providers/parse/google_docai_layout_normalization.py +573 -0
  163. parse_bench/inference/providers/parse/granite_vision.py +515 -0
  164. parse_bench/inference/providers/parse/infinity_parser2.py +704 -0
  165. parse_bench/inference/providers/parse/kdl_frontier_nano.py +3327 -0
  166. parse_bench/inference/providers/parse/landingai.py +452 -0
  167. parse_bench/inference/providers/parse/liteparse.py +350 -0
  168. parse_bench/inference/providers/parse/llamaparse.py +677 -0
  169. parse_bench/inference/providers/parse/llamaparse_v2_normalization.py +1013 -0
  170. parse_bench/inference/providers/parse/markitdown.py +138 -0
  171. parse_bench/inference/providers/parse/mineru25.py +405 -0
  172. parse_bench/inference/providers/parse/mineru2605pro.py +432 -0
  173. parse_bench/inference/providers/parse/mineru_diffusion.py +371 -0
  174. parse_bench/inference/providers/parse/mistral_ocr.py +546 -0
  175. parse_bench/inference/providers/parse/nemotron_omni.py +473 -0
  176. parse_bench/inference/providers/parse/oi_parser.py +222 -0
  177. parse_bench/inference/providers/parse/openai.py +740 -0
  178. parse_bench/inference/providers/parse/opendataloader.py +152 -0
  179. parse_bench/inference/providers/parse/paddleocr.py +624 -0
  180. parse_bench/inference/providers/parse/pdf_inspector.py +142 -0
  181. parse_bench/inference/providers/parse/pulse.py +785 -0
  182. parse_bench/inference/providers/parse/pymupdf.py +207 -0
  183. parse_bench/inference/providers/parse/pymupdf4llm.py +356 -0
  184. parse_bench/inference/providers/parse/pypdf.py +179 -0
  185. parse_bench/inference/providers/parse/qwen.py +678 -0
  186. parse_bench/inference/providers/parse/rakedoc_nano.py +70 -0
  187. parse_bench/inference/providers/parse/reducto.py +546 -0
  188. parse_bench/inference/providers/parse/surya2.py +372 -0
  189. parse_bench/inference/providers/parse/tesseract.py +301 -0
  190. parse_bench/inference/providers/parse/textract.py +694 -0
  191. parse_bench/inference/providers/parse/unlimitedocr.py +346 -0
  192. parse_bench/inference/providers/parse/unstructured.py +485 -0
  193. parse_bench/inference/providers/parse/warp_ingest.py +199 -0
  194. parse_bench/inference/providers/registry.py +49 -0
  195. parse_bench/inference/renormalize.py +170 -0
  196. parse_bench/inference/runner.py +2023 -0
  197. parse_bench/layout_label_mapping.py +424 -0
  198. parse_bench/layout_projection.py +179 -0
  199. parse_bench/pipeline/__init__.py +1 -0
  200. parse_bench/pipeline/cli.py +549 -0
  201. parse_bench/schemas/__init__.py +33 -0
  202. parse_bench/schemas/evaluation.py +93 -0
  203. parse_bench/schemas/extract_output.py +36 -0
  204. parse_bench/schemas/layout_detection_output.py +545 -0
  205. parse_bench/schemas/layout_ontology.py +315 -0
  206. parse_bench/schemas/metrics.py +69 -0
  207. parse_bench/schemas/parse_output.py +152 -0
  208. parse_bench/schemas/pipeline.py +22 -0
  209. parse_bench/schemas/pipeline_io.py +106 -0
  210. parse_bench/schemas/product.py +97 -0
  211. parse_bench/test_cases/__init__.py +25 -0
  212. parse_bench/test_cases/bbox_value_strict_comparator.py +880 -0
  213. parse_bench/test_cases/extract_field_paths.py +164 -0
  214. parse_bench/test_cases/layout_attribution_generation.py +287 -0
  215. parse_bench/test_cases/loader.py +652 -0
  216. parse_bench/test_cases/parse_rule_schemas.py +1071 -0
  217. parse_bench/test_cases/rule_filters.py +32 -0
  218. parse_bench/test_cases/rule_ids.py +107 -0
  219. parse_bench/test_cases/schema.py +427 -0
  220. parse_bench/utils/__init__.py +15 -0
  221. parse_bench/utils/gemini_layout_utils.py +670 -0
  222. parse_bench/utils/text_aggregation.py +100 -0
  223. parse_bench-1.0.0.dist-info/METADATA +476 -0
  224. parse_bench-1.0.0.dist-info/RECORD +227 -0
  225. parse_bench-1.0.0.dist-info/WHEEL +4 -0
  226. parse_bench-1.0.0.dist-info/entry_points.txt +2 -0
  227. parse_bench-1.0.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,955 @@
1
+ """Table parsing utilities for parse evaluation.
2
+
3
+ Ported from OlmOCR bench table parsing logic.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass, field
9
+
10
+ import numpy as np
11
+ from bs4 import BeautifulSoup, Tag
12
+ from rapidfuzz import fuzz
13
+
14
+ from parse_bench.evaluation.metrics.parse.utils import normalize_text
15
+
16
+ # Mappings from ASCII digits to Unicode super-/subscript equivalents.
17
+ # Used so that ``<sup>1</sup>`` is stored as ``¹`` — the same codepoint a
18
+ # provider might already emit — and ``normalize_text`` can strip both
19
+ # representations uniformly.
20
+ _SUPERSCRIPT_DIGITS = "\u2070\u00b9\u00b2\u00b3\u2074\u2075\u2076\u2077\u2078\u2079"
21
+ _SUBSCRIPT_DIGITS = "\u2080\u2081\u2082\u2083\u2084\u2085\u2086\u2087\u2088\u2089"
22
+ _ASCII_TO_SUPERSCRIPT = dict(zip("0123456789", _SUPERSCRIPT_DIGITS, strict=True))
23
+ _ASCII_TO_SUBSCRIPT = dict(zip("0123456789", _SUBSCRIPT_DIGITS, strict=True))
24
+
25
+ # The content-preserving inline styling elements, mirroring
26
+ # ``_INLINE_STYLE_MARKER_RUN_RE`` in ``utils.py``. ``sup``/``sub`` are excluded
27
+ # for the same reason they are there: they are handled by _sup_sub_to_unicode.
28
+ _INLINE_STYLE_TAGS = frozenset({"b", "i", "u", "s", "del", "strike", "ins", "mark", "span"})
29
+
30
+
31
+ def _separate_abutting_inline_markers(cell: Tag) -> None:
32
+ """Insert a space between two abutting inline styling elements.
33
+
34
+ ``get_text()`` concatenates text nodes with no separator, so a byte-faithful
35
+ ``<u>105</u><s>103</s>`` extracts as ``105103`` and the two page numbers are
36
+ welded into one unmatchable token. This is the cell-text counterpart of
37
+ ``_INLINE_STYLE_MARKER_RUN_RE`` in ``utils.py``: the whole-document path
38
+ folds such a run to a space, but a cell's text never reaches that path with
39
+ its markup intact once tables are replaced by their cell text.
40
+
41
+ A LONE marker still joins, exactly as in ``normalize_text`` - inline tags are
42
+ genuinely used intra-word (Japanese ``<u>`` opening mid-sentence) - so only
43
+ the boundary BETWEEN two marker elements is separated.
44
+ """
45
+ for tag in cell.find_all(lambda t: t.name in _INLINE_STYLE_TAGS):
46
+ nxt = tag.next_sibling
47
+ if isinstance(nxt, Tag) and nxt.name in _INLINE_STYLE_TAGS:
48
+ tag.insert_after(" ")
49
+
50
+
51
+ def _sup_sub_to_unicode(cell: Tag) -> None:
52
+ """Convert ``<sup>``/``<sub>`` digit content to Unicode equivalents.
53
+
54
+ ASCII digits inside the tags are mapped to their Unicode super-/subscript
55
+ codepoints (``1`` → ``¹``). Non-digit characters (parens, letters,
56
+ whitespace) are preserved as-is — e.g. ``<sup>(2)</sup>`` becomes
57
+ ``(²)`` rather than being silently dropped to ``²``, which would glue
58
+ a footnote digit onto the preceding number. After this call,
59
+ ``cell.get_text()`` will contain Unicode super-/subscript digits in
60
+ place of bare ASCII digits that were hidden inside markup.
61
+ """
62
+ for tag in cell.find_all("sup"):
63
+ text = tag.get_text()
64
+ converted = "".join(_ASCII_TO_SUPERSCRIPT.get(c, c) for c in text)
65
+ tag.replace_with(converted)
66
+ for tag in cell.find_all("sub"):
67
+ text = tag.get_text()
68
+ converted = "".join(_ASCII_TO_SUBSCRIPT.get(c, c) for c in text)
69
+ tag.replace_with(converted)
70
+
71
+
72
+ @dataclass
73
+ class TableData:
74
+ """Class to hold table data and metadata about headers."""
75
+
76
+ data: np.ndarray # The actual table data
77
+ header_rows: set[int] = field(default_factory=set) # Indices of rows that are headers
78
+ header_cols: set[int] = field(default_factory=set) # Indices of columns that are headers
79
+ col_headers: dict = field(default_factory=dict) # Maps column index to header text, handling colspan
80
+ row_headers: dict = field(default_factory=dict) # Maps row index to header text, handling rowspan
81
+ # Grid cells that originate from a <th> element (including all cells
82
+ # covered by colspan/rowspan expansion). This lets downstream code
83
+ # answer "is (row, col) from a <th>?" without conflating span expansion
84
+ # with hierarchical header levels in col_headers.
85
+ header_cells: set[tuple[int, int]] = field(default_factory=set)
86
+ # Grid positions that only exist because a cell was expanded across a
87
+ # rowspan/colspan. The originating position is *not* included, so callers
88
+ # that must visit each authored cell exactly once (e.g. bag-of-word
89
+ # extraction) can skip these without losing the cell itself.
90
+ spanned_cells: set[tuple[int, int]] = field(default_factory=set)
91
+ # Rows the source HTML placed inside an explicit <thead>. A subset of
92
+ # ``header_rows``, which also absorbs any row merely *containing* a <th> —
93
+ # including body rows whose label cell is a <th> row header. Keeping the
94
+ # authored <thead> separate lets header analysis tell "the document says
95
+ # this is part of the column-header block" from "this row happens to hold
96
+ # a <th>".
97
+ thead_rows: set[int] = field(default_factory=set)
98
+ # Rows the source HTML placed inside an explicit <tbody>. This preserves
99
+ # the distinction between an implicit top-level <th> header block and a
100
+ # body row that happens to use <th> for its row label.
101
+ tbody_rows: set[int] = field(default_factory=set)
102
+ # Rows the source HTML placed inside an explicit <tfoot>. Footer cells are
103
+ # never column headers for a chart value, even when malformed markup puts
104
+ # the footer before the body.
105
+ tfoot_rows: set[int] = field(default_factory=set)
106
+ # Rows containing an explicit HTML scope declaration. This preserves the
107
+ # author’s column-versus-row intent without changing generic <th> parsing.
108
+ column_scope_rows: set[int] = field(default_factory=set)
109
+ row_scope_rows: set[int] = field(default_factory=set)
110
+ caption: str = field(default="") # <caption> text, which lives inside the table but outside any cell
111
+ context_before: str = field(default="") # Text before table (for chart titles)
112
+ context_after: str = field(default="") # Text after table (for captions)
113
+
114
+
115
+ def _process_table_lines(table_lines: list[str]) -> list[list[str]]:
116
+ """
117
+ Process a list of lines that potentially form a markdown table.
118
+
119
+ Args:
120
+ table_lines: List of strings, each representing a line in a potential markdown table
121
+
122
+ Returns:
123
+ A list of rows, each a list of cell values
124
+ """
125
+ table_data = []
126
+ separator_row_index = None
127
+
128
+ # First, identify the separator row (the row with dashes)
129
+ for i, line in enumerate(table_lines):
130
+ # Check if this looks like a separator row (contains mostly dashes)
131
+ content_without_pipes = line.replace("|", "").strip()
132
+ if content_without_pipes and all(c in "- :" for c in content_without_pipes):
133
+ separator_row_index = i
134
+ break
135
+
136
+ # Process each line, filtering out the separator row
137
+ for i, line in enumerate(table_lines):
138
+ # Skip the separator row
139
+ if i == separator_row_index:
140
+ continue
141
+
142
+ # Skip lines that are entirely formatting
143
+ if line.strip() and all(c in "- :|" for c in line):
144
+ continue
145
+
146
+ # Process the cells in this row
147
+ cells = [cell.strip() for cell in line.split("|")]
148
+
149
+ # Remove empty cells at the beginning and end (caused by leading/trailing pipes)
150
+ if cells and cells[0] == "":
151
+ cells = cells[1:]
152
+ if cells and cells[-1] == "":
153
+ cells = cells[:-1]
154
+
155
+ if cells: # Only add non-empty rows
156
+ table_data.append(cells)
157
+
158
+ return table_data
159
+
160
+
161
+ def parse_markdown_tables(md_content: str) -> list[TableData]:
162
+ """
163
+ Extract and parse all markdown tables from the provided content.
164
+
165
+ Args:
166
+ md_content: The markdown content containing tables
167
+
168
+ Returns:
169
+ A list of TableData objects, each containing the table data and header information
170
+ """
171
+ # Split the content into lines and process line by line
172
+ lines = md_content.strip().split("\n")
173
+
174
+ parsed_tables = []
175
+ current_table_lines = []
176
+ table_start_line = -1
177
+ in_table = False
178
+
179
+ # Identify potential tables by looking for lines with pipe characters
180
+ for line_idx, line in enumerate(lines):
181
+ # Check if this line has pipe characters (a table row indicator)
182
+ if "|" in line:
183
+ # If we weren't in a table before, start a new one
184
+ if not in_table:
185
+ in_table = True
186
+ table_start_line = line_idx
187
+ current_table_lines = [line]
188
+ else:
189
+ # Continue adding to the current table
190
+ current_table_lines.append(line)
191
+ else:
192
+ # No pipes in this line, so if we were in a table, we've reached its end
193
+ if in_table:
194
+ table_end_line = line_idx
195
+ # Process the completed table if it has at least 2 rows
196
+ if len(current_table_lines) >= 2:
197
+ table_data = _process_table_lines(current_table_lines)
198
+ if table_data and len(table_data) > 0:
199
+ # Convert to numpy array for easier manipulation
200
+ max_cols = max(len(row) for row in table_data)
201
+ padded_data = [row + [""] * (max_cols - len(row)) for row in table_data]
202
+ table_array = np.array(padded_data, dtype=object)
203
+
204
+ # In markdown tables, the first row is typically a header row
205
+ header_rows = {0} if len(table_array) > 0 else set()
206
+
207
+ # Set up col_headers with first row headers for each column
208
+ col_headers = {}
209
+ if len(table_array) > 0:
210
+ for col_idx in range(table_array.shape[1]):
211
+ if col_idx < len(table_array[0]):
212
+ col_headers[col_idx] = [(0, str(table_array[0, col_idx]))]
213
+
214
+ # Set up row_headers with first column headers for each row
215
+ row_headers = {}
216
+ if table_array.shape[1] > 0:
217
+ # Skip header row
218
+ for row_idx in range(1, table_array.shape[0]):
219
+ # First column as heading
220
+ row_headers[row_idx] = [(0, str(table_array[row_idx, 0]))]
221
+
222
+ # Extract context (up to 5 lines before, 2 lines after)
223
+ context_before = "\n".join(lines[max(0, table_start_line - 5) : table_start_line])
224
+ context_after = "\n".join(lines[table_end_line : min(len(lines), table_end_line + 2)])
225
+
226
+ # Create TableData object
227
+ parsed_tables.append(
228
+ TableData(
229
+ data=table_array,
230
+ header_rows=header_rows,
231
+ # First column as header
232
+ header_cols={0} if table_array.shape[1] > 0 else set(),
233
+ col_headers=col_headers,
234
+ row_headers=row_headers,
235
+ context_before=context_before,
236
+ context_after=context_after,
237
+ )
238
+ )
239
+ in_table = False
240
+
241
+ # Process the last table if we're still tracking one at the end of the file
242
+ if in_table and len(current_table_lines) >= 2:
243
+ table_end_line = len(lines)
244
+ table_data = _process_table_lines(current_table_lines)
245
+ if table_data and len(table_data) > 0:
246
+ # Convert to numpy array
247
+ max_cols = max(len(row) for row in table_data)
248
+ padded_data = [row + [""] * (max_cols - len(row)) for row in table_data]
249
+ table_array = np.array(padded_data, dtype=object)
250
+
251
+ # In markdown tables, the first row is typically a header row
252
+ header_rows = {0} if len(table_array) > 0 else set()
253
+
254
+ # Set up col_headers with first row headers for each column
255
+ col_headers = {}
256
+ if len(table_array) > 0:
257
+ for col_idx in range(table_array.shape[1]):
258
+ if col_idx < len(table_array[0]):
259
+ col_headers[col_idx] = [(0, str(table_array[0, col_idx]))]
260
+
261
+ # Set up row_headers with first column headers for each row
262
+ row_headers = {}
263
+ if table_array.shape[1] > 0:
264
+ # Skip header row
265
+ for row_idx in range(1, table_array.shape[0]):
266
+ # First column as heading
267
+ row_headers[row_idx] = [(0, str(table_array[row_idx, 0]))]
268
+
269
+ # Extract context (up to 5 lines before, 2 lines after)
270
+ context_before = "\n".join(lines[max(0, table_start_line - 5) : table_start_line])
271
+ context_after = "\n".join(lines[table_end_line : min(len(lines), table_end_line + 2)])
272
+
273
+ # Create TableData object
274
+ parsed_tables.append(
275
+ TableData(
276
+ data=table_array,
277
+ header_rows=header_rows,
278
+ # First column as header
279
+ header_cols={0} if table_array.shape[1] > 0 else set(),
280
+ col_headers=col_headers,
281
+ row_headers=row_headers,
282
+ context_before=context_before,
283
+ context_after=context_after,
284
+ )
285
+ )
286
+
287
+ return parsed_tables
288
+
289
+
290
+ def parse_html_tables(html_content: str) -> list[TableData]:
291
+ """
292
+ Extract and parse all HTML tables from the provided content.
293
+ Identifies header rows and columns, and maps them properly handling rowspan/colspan.
294
+
295
+ Args:
296
+ html_content: The HTML content containing tables
297
+
298
+ Returns:
299
+ A list of TableData objects, each containing the table data and header information
300
+ """
301
+ soup = BeautifulSoup(html_content, "lxml")
302
+ all_tables = soup.find_all("table")
303
+
304
+ # Filter to top-level tables (skip tables nested inside other tables)
305
+ top_level_tables = []
306
+ for t in all_tables:
307
+ if not any(p.name == "table" for p in t.parents if hasattr(p, "name")):
308
+ top_level_tables.append(t)
309
+
310
+ parsed_tables = []
311
+
312
+ for table in top_level_tables:
313
+ # Replace nested tables with their text content so their <tr>
314
+ # elements don't leak into the outer table's row list
315
+ for nested in table.find_all("table"):
316
+ nested.replace_with(nested.get_text(" ", strip=True))
317
+
318
+ rows = table.find_all(["tr"])
319
+ # BeautifulSoup compares Tag instances structurally, so identical
320
+ # ``<tr>`` elements can compare equal. Provenance needs each source
321
+ # row's physical position, not the first structurally equal row.
322
+ row_indices = {id(row): row_idx for row_idx, row in enumerate(rows)}
323
+
324
+ # ``scope=colgroup`` and ``scope=rowgroup`` refer to the authored
325
+ # structural group, not merely to the cell's explicit span. Preserve
326
+ # those groups here so scoped headers can be expanded semantically
327
+ # without pretending that the source cell itself had a larger span.
328
+ colgroup_ranges: list[range] = []
329
+ next_col = 0
330
+ for colgroup in table.find_all("colgroup", recursive=False):
331
+ cols = colgroup.find_all("col", recursive=False)
332
+ width = 0
333
+ for col in cols:
334
+ width += int(col.get("span", 1)) # type: ignore[arg-type]
335
+ if not cols:
336
+ width = int(colgroup.get("span", 1)) # type: ignore[arg-type]
337
+ colgroup_ranges.append(range(next_col, next_col + width))
338
+ next_col += width
339
+
340
+ # Extract <caption> text if present (used as chart title context)
341
+ caption_elem = table.find("caption")
342
+ caption_text = caption_elem.get_text(strip=True) if caption_elem else ""
343
+
344
+ header_rows = set()
345
+ header_cols = set()
346
+ # Maps column index to all header cells above it
347
+ col_headers: dict[int, list[tuple[int, str]]] = {}
348
+ # Maps row index to all header cells to its left
349
+ row_headers: dict[int, list[tuple[int, str]]] = {}
350
+
351
+ row_group_rows: dict[int, set[int]] = {}
352
+
353
+ # Find rows inside thead tags - these are definitely header rows
354
+ thead_row_indices: set[int] = set()
355
+ for thead in table.find_all("thead", recursive=False):
356
+ section_rows = {row_indices[id(tr)] for tr in thead.find_all("tr") if id(tr) in row_indices}
357
+ thead_row_indices |= section_rows
358
+ for row_idx in section_rows:
359
+ row_group_rows[row_idx] = section_rows
360
+ header_rows |= thead_row_indices
361
+
362
+ tbody_row_indices: set[int] = set()
363
+ for tbody in table.find_all("tbody", recursive=False):
364
+ section_rows = {row_indices[id(tr)] for tr in tbody.find_all("tr") if id(tr) in row_indices}
365
+ tbody_row_indices |= section_rows
366
+ for row_idx in section_rows:
367
+ row_group_rows[row_idx] = section_rows
368
+
369
+ tfoot_row_indices: set[int] = set()
370
+ for tfoot in table.find_all("tfoot", recursive=False):
371
+ section_rows = {row_indices[id(tr)] for tr in tfoot.find_all("tr") if id(tr) in row_indices}
372
+ tfoot_row_indices |= section_rows
373
+ for row_idx in section_rows:
374
+ row_group_rows[row_idx] = section_rows
375
+
376
+ # Initialize a grid to track filled cells due to rowspan/colspan
377
+ cell_grid = {}
378
+ header_cells: set[tuple[int, int]] = set()
379
+ column_scope_rows: set[int] = set()
380
+ row_scope_rows: set[int] = set()
381
+ spanned_cells: set[tuple[int, int]] = set()
382
+ col_span_info = {} # Tracks which columns contain headers
383
+ row_span_info = {} # Tracks which rows contain headers
384
+
385
+ # First pass: process each row to build the raw table data and identify headers
386
+ for row_idx, row in enumerate(rows):
387
+ cells = row.find_all(["th", "td"])
388
+ col_idx = 0
389
+
390
+ for cell in cells:
391
+ # Skip cells that are already filled by rowspan/colspan
392
+ while (row_idx, col_idx) in cell_grid:
393
+ col_idx += 1
394
+
395
+ # Get cell text — replace <br> with space before extraction
396
+ # so that tag boundaries don't merge adjacent words
397
+ # (mirrors the approach used in _resolve_table at line ~451)
398
+ for br in cell.find_all("br"):
399
+ br.replace_with(" ")
400
+ # Convert <sup>/<sub> digit content to Unicode equivalents
401
+ # so that "Name<sup>1</sup>" becomes "Name¹", matching the
402
+ # representation when sources already use Unicode superscripts.
403
+ _sup_sub_to_unicode(cell)
404
+ # Two abutting inline markers are a token boundary, not a join.
405
+ _separate_abutting_inline_markers(cell)
406
+ cell_text = cell.get_text().strip()
407
+
408
+ # Check if this is a header cell
409
+ is_header = cell.name == "th"
410
+ scope = str(cell.get("scope", "")).lower()
411
+ if is_header:
412
+ header_rows.add(row_idx)
413
+ header_cols.add(col_idx)
414
+ col_span_info[col_idx] = True
415
+ row_span_info[row_idx] = True
416
+ if row_idx not in tfoot_row_indices:
417
+ if scope in {"col", "colgroup"}:
418
+ column_scope_rows.add(row_idx)
419
+ elif scope in {"row", "rowgroup"}:
420
+ row_scope_rows.add(row_idx)
421
+
422
+ # Get rowspan and colspan
423
+ rowspan = int(cell.get("rowspan", 1)) # type: ignore[arg-type]
424
+ colspan = int(cell.get("colspan", 1)) # type: ignore[arg-type]
425
+
426
+ # Fill the grid for all cells spanned by this cell
427
+ for r in range(row_idx, row_idx + rowspan):
428
+ for c in range(col_idx, col_idx + colspan):
429
+ cell_grid[(r, c)] = cell_text
430
+ if (r, c) != (row_idx, col_idx):
431
+ spanned_cells.add((r, c))
432
+ if is_header:
433
+ header_cells.add((r, c))
434
+
435
+ # Update col_headers and row_headers if this is a header
436
+ if is_header and row_idx not in tfoot_row_indices:
437
+ # Add to col_headers for all columns this cell spans
438
+ if scope not in {"row", "rowgroup"}:
439
+ scoped_columns = range(col_idx, col_idx + colspan)
440
+ if scope == "colgroup":
441
+ scoped_columns = next(
442
+ (group for group in colgroup_ranges if col_idx in group),
443
+ scoped_columns,
444
+ )
445
+ for c in scoped_columns:
446
+ if c not in col_headers:
447
+ col_headers[c] = []
448
+ col_headers[c].append((row_idx, cell_text))
449
+
450
+ # Add to row_headers for all rows this cell spans
451
+ if scope not in {"col", "colgroup"}:
452
+ scoped_rows = set(range(row_idx, row_idx + rowspan))
453
+ if scope == "rowgroup":
454
+ scoped_rows = row_group_rows.get(row_idx, scoped_rows)
455
+ for r in scoped_rows:
456
+ if r not in row_headers:
457
+ row_headers[r] = []
458
+ row_headers[r].append((col_idx, cell_text))
459
+
460
+ col_idx += colspan
461
+
462
+ if cell_grid:
463
+ max_grid_row = max(r for r, c in cell_grid) + 1
464
+ max_grid_col = max(c for r, c in cell_grid) + 1
465
+ table_array = np.empty((max_grid_row, max_grid_col), dtype=object)
466
+ table_array[:] = ""
467
+ for (r, c), text in cell_grid.items():
468
+ table_array[r, c] = text
469
+ else:
470
+ table_array = np.array([[]], dtype=object)
471
+
472
+ # Extract context IMMEDIATELY before and after the table
473
+ # Simple approach: just get the previous and next siblings up to next table
474
+ context_before_parts = [] # type: ignore[var-annotated]
475
+ context_after_parts = []
476
+
477
+ # Get text IMMEDIATELY before table (up to 3 siblings or until we hit another table)
478
+ prev = table.previous_sibling
479
+ for _ in range(3):
480
+ if prev is None:
481
+ break
482
+ # Stop if we hit another table
483
+ if hasattr(prev, "name") and prev.name == "table":
484
+ break
485
+ # Get text content
486
+ if hasattr(prev, "get_text"):
487
+ text = prev.get_text(strip=True)
488
+ elif isinstance(prev, str):
489
+ text = prev.strip()
490
+ else:
491
+ text = ""
492
+ if text:
493
+ context_before_parts.insert(0, text)
494
+ prev = prev.previous_sibling
495
+
496
+ # Take last 300 chars (closest to table), not first 300
497
+ full_context_before = " ".join(context_before_parts)
498
+ context_before = full_context_before[-300:] if len(full_context_before) > 300 else full_context_before
499
+
500
+ # Prepend caption to context_before (caption acts as chart title)
501
+ if caption_text:
502
+ caption_markup = f"<caption>{caption_text}</caption>"
503
+ context_before = f"{caption_markup} {context_before}" if context_before else caption_markup
504
+
505
+ # Get text IMMEDIATELY after table (up to 2 siblings or until we hit another table)
506
+ next_elem = table.next_sibling
507
+ for _ in range(2):
508
+ if next_elem is None:
509
+ break
510
+ # Stop if we hit another table
511
+ if hasattr(next_elem, "name") and next_elem.name == "table":
512
+ break
513
+ # Get text content
514
+ if hasattr(next_elem, "get_text"):
515
+ text = next_elem.get_text(strip=True)
516
+ elif isinstance(next_elem, str):
517
+ text = next_elem.strip()
518
+ else:
519
+ text = ""
520
+ if text:
521
+ context_after_parts.append(text)
522
+ next_elem = next_elem.next_sibling
523
+
524
+ context_after = " ".join(context_after_parts)[:200]
525
+
526
+ # Create TableData object
527
+ parsed_tables.append(
528
+ TableData(
529
+ data=table_array,
530
+ header_rows=header_rows,
531
+ header_cols=header_cols,
532
+ col_headers=col_headers,
533
+ row_headers=row_headers,
534
+ header_cells=header_cells,
535
+ spanned_cells=spanned_cells,
536
+ thead_rows=thead_row_indices,
537
+ tbody_rows=tbody_row_indices,
538
+ tfoot_rows=tfoot_row_indices,
539
+ column_scope_rows=column_scope_rows,
540
+ row_scope_rows=row_scope_rows,
541
+ caption=caption_text,
542
+ context_before=context_before,
543
+ context_after=context_after,
544
+ )
545
+ )
546
+
547
+ return parsed_tables
548
+
549
+
550
+ # =============================================================================
551
+ # Grid-based table parsing for hierarchy tests
552
+ # =============================================================================
553
+
554
+
555
+ @dataclass
556
+ class ResolvedCell:
557
+ """Metadata for a cell in the resolved grid."""
558
+
559
+ text: str
560
+ original_row: int
561
+ original_col: int
562
+ colspan: int
563
+ rowspan: int
564
+ is_header: bool # True if <th> element
565
+
566
+
567
+ @dataclass
568
+ class ResolvedGrid:
569
+ """A 2D grid of cells with span information resolved."""
570
+
571
+ cells: list[list[ResolvedCell | None]] # [row][col] -> ResolvedCell or None
572
+ num_rows: int
573
+ num_cols: int
574
+ cell_positions: dict[str, list[tuple[int, int]]] # text -> list of (row, col) positions
575
+
576
+
577
+ def resolve_html_table_grid(table_html: str) -> ResolvedGrid | None:
578
+ """
579
+ Parse an HTML table and resolve colspan/rowspan to a 2D grid.
580
+
581
+ Args:
582
+ table_html: HTML string containing a single table
583
+
584
+ Returns:
585
+ ResolvedGrid with cell positions and metadata, or None if no table found
586
+ """
587
+ soup = BeautifulSoup(table_html, "lxml")
588
+ table = soup.find("table")
589
+ if not table:
590
+ return None
591
+
592
+ rows = table.find_all("tr")
593
+ if not rows:
594
+ return None
595
+
596
+ # First pass: determine grid dimensions
597
+ max_cols = 0
598
+ for row in rows:
599
+ col_count = sum(int(cell.get("colspan", 1)) for cell in row.find_all(["td", "th"])) # type: ignore[arg-type, misc]
600
+ max_cols = max(max_cols, col_count)
601
+
602
+ num_rows = len(rows)
603
+ num_cols = max_cols
604
+
605
+ if num_rows == 0 or num_cols == 0:
606
+ return None
607
+
608
+ # Initialize empty grid
609
+ grid: list[list[ResolvedCell | None]] = [[None for _ in range(num_cols)] for _ in range(num_rows)]
610
+ cell_positions: dict[str, list[tuple[int, int]]] = {}
611
+
612
+ # Second pass: fill the grid
613
+ for row_idx, row in enumerate(rows):
614
+ col_idx = 0
615
+ for cell in row.find_all(["td", "th"]):
616
+ # Skip positions already filled by rowspan from above
617
+ while col_idx < num_cols and grid[row_idx][col_idx] is not None:
618
+ col_idx += 1
619
+
620
+ if col_idx >= num_cols:
621
+ break
622
+
623
+ # Get cell properties
624
+ colspan = int(cell.get("colspan", 1)) # type: ignore[arg-type]
625
+ rowspan = int(cell.get("rowspan", 1)) # type: ignore[arg-type]
626
+ is_header = cell.name == "th"
627
+
628
+ # Get text, replacing <br> with newlines first
629
+ for br in cell.find_all("br"):
630
+ br.replace_with("\n")
631
+ # Convert <sup>/<sub> digit content to Unicode equivalents
632
+ _sup_sub_to_unicode(cell)
633
+ text = cell.get_text().strip()
634
+ text = normalize_text(text)
635
+
636
+ # Create cell metadata
637
+ resolved_cell = ResolvedCell(
638
+ text=text,
639
+ original_row=row_idx,
640
+ original_col=col_idx,
641
+ colspan=colspan,
642
+ rowspan=rowspan,
643
+ is_header=is_header,
644
+ )
645
+
646
+ # Fill grid positions for this cell
647
+ for r_offset in range(rowspan):
648
+ for c_offset in range(colspan):
649
+ target_row = row_idx + r_offset
650
+ target_col = col_idx + c_offset
651
+ if target_row < num_rows and target_col < num_cols:
652
+ grid[target_row][target_col] = resolved_cell
653
+ # Track positions by text
654
+ if text:
655
+ if text not in cell_positions:
656
+ cell_positions[text] = []
657
+ cell_positions[text].append((target_row, target_col))
658
+
659
+ col_idx += colspan
660
+
661
+ return ResolvedGrid(
662
+ cells=grid,
663
+ num_rows=num_rows,
664
+ num_cols=num_cols,
665
+ cell_positions=cell_positions,
666
+ )
667
+
668
+
669
+ def find_all_html_tables(content: str) -> list[ResolvedGrid]:
670
+ """
671
+ Find and resolve all HTML tables in content.
672
+
673
+ Args:
674
+ content: HTML or markdown content containing tables
675
+
676
+ Returns:
677
+ List of ResolvedGrid objects
678
+ """
679
+ soup = BeautifulSoup(content, "lxml")
680
+ tables = soup.find_all("table")
681
+
682
+ grids = []
683
+ for table in tables:
684
+ grid = resolve_html_table_grid(str(table))
685
+ if grid:
686
+ grids.append(grid)
687
+
688
+ return grids
689
+
690
+
691
+ class AnchorMatchResult:
692
+ """Result of find_table_by_anchors with match quality info."""
693
+
694
+ def __init__(
695
+ self,
696
+ grid: ResolvedGrid | None,
697
+ status: str,
698
+ num_candidates: int = 0,
699
+ ):
700
+ self.grid = grid
701
+ # "unique" = one table matched uniquely
702
+ # "ambiguous" = anchors found in multiple tables, no unique winner
703
+ # "no_match" = anchors not found in any table
704
+ self.status = status
705
+ self.num_candidates = num_candidates
706
+
707
+ @property
708
+ def is_ambiguous(self) -> bool:
709
+ return self.status == "ambiguous"
710
+
711
+
712
+ def find_table_by_anchors(
713
+ grids: list[ResolvedGrid],
714
+ anchor_cells: list[str],
715
+ threshold: float = 0.8,
716
+ ) -> AnchorMatchResult:
717
+ """
718
+ Find the table that contains ANY of the anchor cells uniquely.
719
+
720
+ Strategy:
721
+ 1. For each anchor cell, find which tables contain it (using fuzzy matching)
722
+ 2. If an anchor appears in exactly ONE table, that's a strong signal
723
+ 3. Return the table with the most anchor matches
724
+
725
+ This handles OCR errors: even if some anchors don't match, others will.
726
+
727
+ Args:
728
+ grids: List of ResolvedGrid objects to search
729
+ anchor_cells: List of anchor cell texts that uniquely identify the target table
730
+ threshold: Minimum similarity ratio (0-1) for fuzzy matching
731
+
732
+ Returns:
733
+ AnchorMatchResult with the matched grid (if any) and match quality status
734
+ """
735
+ if not anchor_cells or not grids:
736
+ return AnchorMatchResult(None, "no_match")
737
+
738
+ # Track votes: grid_index -> number of unique anchor matches
739
+ table_votes: dict[int, int] = {}
740
+ # Track all tables that contain any anchor (for ambiguity detection)
741
+ all_matching_tables: set[int] = set()
742
+
743
+ for anchor in anchor_cells:
744
+ normalized_anchor = normalize_text(anchor)
745
+ tables_with_anchor: list[int] = []
746
+
747
+ for grid_idx, grid in enumerate(grids):
748
+ found_in_grid = False
749
+ for row in grid.cells:
750
+ if found_in_grid:
751
+ break
752
+ for cell in row:
753
+ if cell is None:
754
+ continue
755
+ similarity = fuzz.ratio(normalized_anchor, cell.text) / 100.0
756
+ if similarity >= threshold:
757
+ tables_with_anchor.append(grid_idx)
758
+ found_in_grid = True
759
+ break
760
+
761
+ all_matching_tables.update(tables_with_anchor)
762
+
763
+ # If this anchor appears in exactly ONE table, it's a strong signal
764
+ unique_tables = set(tables_with_anchor)
765
+ if len(unique_tables) == 1:
766
+ matched_idx = unique_tables.pop()
767
+ table_votes[matched_idx] = table_votes.get(matched_idx, 0) + 1
768
+
769
+ if table_votes:
770
+ best_table_idx = max(table_votes, key=lambda x: table_votes[x])
771
+ return AnchorMatchResult(grids[best_table_idx], "unique")
772
+
773
+ if all_matching_tables:
774
+ return AnchorMatchResult(None, "ambiguous", len(all_matching_tables))
775
+
776
+ return AnchorMatchResult(None, "no_match")
777
+
778
+
779
+ def merge_preceding_titles_into_tables(expected: str, actual: str) -> str:
780
+ """Normalize predicted HTML by merging preceding text into tables as full-width title rows.
781
+
782
+ When a ground-truth table starts with a single full-width colspan row (acting
783
+ as a table title), but the predicted output has that same text as a heading or
784
+ paragraph immediately before the ``<table>``, the predicted table will be
785
+ missing that row and score lower on structural metrics.
786
+
787
+ This function detects such cases and inserts the preceding text into the
788
+ predicted table as a ``<tr><th colspan="...">title</th></tr>`` first row,
789
+ so the two tables align structurally.
790
+
791
+ Args:
792
+ expected: Ground-truth markdown/HTML content.
793
+ actual: Predicted markdown/HTML content to normalize.
794
+
795
+ Returns:
796
+ The ``actual`` string with preceding titles merged into tables where
797
+ appropriate.
798
+ """
799
+ if not expected or not actual:
800
+ return actual
801
+
802
+ # --- Step 1: collect title texts from GT tables whose first row is full-width ---
803
+ gt_titles: list[str] = []
804
+ gt_soup = BeautifulSoup(expected, "lxml")
805
+ for table in gt_soup.find_all("table"):
806
+ first_row = table.find("tr")
807
+ if first_row is None:
808
+ continue
809
+ cells = first_row.find_all(["th", "td"])
810
+ if len(cells) != 1:
811
+ continue
812
+ cell = cells[0]
813
+ colspan = int(cell.get("colspan", 1)) # type: ignore[arg-type]
814
+ if colspan <= 1:
815
+ continue
816
+ # Verify this cell actually spans all columns by checking the next row
817
+ second_row = first_row.find_next_sibling("tr")
818
+ if second_row is None:
819
+ continue
820
+ second_row_col_count = sum(
821
+ int(c.get("colspan", 1)) # type: ignore[arg-type, misc]
822
+ for c in second_row.find_all(["th", "td"])
823
+ )
824
+ if colspan < second_row_col_count:
825
+ continue
826
+ title_text = cell.get_text(strip=True)
827
+ if title_text:
828
+ gt_titles.append(title_text)
829
+
830
+ if not gt_titles:
831
+ return actual
832
+
833
+ # --- Step 2: for each predicted table, check if preceding text matches a GT title ---
834
+ pred_soup = BeautifulSoup(actual, "lxml")
835
+ modified = False
836
+
837
+ for table in pred_soup.find_all("table"):
838
+ # Skip tables that already start with a full-width row
839
+ first_row = table.find("tr")
840
+ if first_row is not None:
841
+ first_cells = first_row.find_all(["th", "td"])
842
+ if len(first_cells) == 1:
843
+ first_colspan = int(first_cells[0].get("colspan", 1)) # type: ignore[arg-type]
844
+ if first_colspan > 1:
845
+ continue # Already has a title row
846
+
847
+ # Collect preceding sibling text (headings, paragraphs, bare text)
848
+ preceding_text = ""
849
+ prev_elem = table.previous_sibling
850
+ # Skip whitespace-only text nodes
851
+ while prev_elem is not None and isinstance(prev_elem, str) and not prev_elem.strip():
852
+ prev_elem = prev_elem.previous_sibling
853
+ if prev_elem is not None:
854
+ if hasattr(prev_elem, "get_text"):
855
+ preceding_text = prev_elem.get_text(strip=True)
856
+ elif isinstance(prev_elem, str):
857
+ preceding_text = prev_elem.strip()
858
+
859
+ if not preceding_text:
860
+ continue
861
+
862
+ # Fuzzy-match against GT titles
863
+ normalized_preceding = normalize_text(preceding_text)
864
+ best_match_ratio = 0.0
865
+ for gt_title in gt_titles:
866
+ normalized_gt = normalize_text(gt_title)
867
+ ratio = fuzz.ratio(normalized_preceding, normalized_gt) / 100.0
868
+ if ratio > best_match_ratio:
869
+ best_match_ratio = ratio
870
+
871
+ if best_match_ratio < 0.8:
872
+ continue
873
+
874
+ # Determine column count from the table's header/first data row
875
+ col_count = 0
876
+ for row in table.find_all("tr"):
877
+ row_cols = sum(
878
+ int(c.get("colspan", 1)) # type: ignore[arg-type, misc]
879
+ for c in row.find_all(["th", "td"])
880
+ )
881
+ if row_cols > col_count:
882
+ col_count = row_cols
883
+
884
+ if col_count < 2:
885
+ continue
886
+
887
+ # Insert a new full-width title row at the top of the table
888
+ new_row = pred_soup.new_tag("tr")
889
+ new_th = pred_soup.new_tag("th", colspan=str(col_count))
890
+ new_th.string = preceding_text
891
+ new_row.append(new_th)
892
+
893
+ # Insert into <thead> if it exists, otherwise at the start of the table
894
+ thead = table.find("thead")
895
+ if thead:
896
+ thead.insert(0, new_row)
897
+ else:
898
+ # Insert before the first <tr>
899
+ first_tr = table.find("tr")
900
+ if first_tr:
901
+ first_tr.insert_before(new_row)
902
+ else:
903
+ table.append(new_row)
904
+
905
+ # Remove the preceding element that contained the title
906
+ if prev_elem is not None:
907
+ prev_elem.extract()
908
+
909
+ modified = True
910
+
911
+ if not modified:
912
+ return actual
913
+
914
+ # Serialize back to string — use the body content to avoid extra <html><body> wrapper
915
+ body = pred_soup.find("body")
916
+ if body:
917
+ return body.decode_contents()
918
+ return str(pred_soup)
919
+
920
+
921
+ def find_cell_in_grids(
922
+ grids: list[ResolvedGrid],
923
+ cell_text: str,
924
+ threshold: float = 0.8,
925
+ ) -> tuple[ResolvedGrid, ResolvedCell, int, int] | None:
926
+ """
927
+ Find a cell by text in a list of grids using fuzzy matching.
928
+
929
+ Args:
930
+ grids: List of ResolvedGrid objects to search
931
+ cell_text: Text to search for
932
+ threshold: Minimum similarity ratio (0-1)
933
+
934
+ Returns:
935
+ Tuple of (grid, cell, row_idx, col_idx) or None if not found
936
+ """
937
+ normalized_search = normalize_text(cell_text)
938
+ best_match: tuple[ResolvedGrid, ResolvedCell, int, int] | None = None
939
+ best_score = 0.0
940
+
941
+ for grid in grids:
942
+ for row_idx, row in enumerate(grid.cells):
943
+ for col_idx, cell in enumerate(row):
944
+ if cell is None:
945
+ continue
946
+ # Only check original cell position to avoid duplicate checks
947
+ if cell.original_row != row_idx or cell.original_col != col_idx:
948
+ continue
949
+
950
+ similarity = fuzz.ratio(normalized_search, cell.text) / 100.0
951
+ if similarity > best_score and similarity >= threshold:
952
+ best_score = similarity
953
+ best_match = (grid, cell, row_idx, col_idx)
954
+
955
+ return best_match