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.
- parse_bench/__init__.py +3 -0
- parse_bench/analysis/__init__.py +6 -0
- parse_bench/analysis/aggregation_report.py +582 -0
- parse_bench/analysis/cli.py +472 -0
- parse_bench/analysis/comparison.py +382 -0
- parse_bench/analysis/comparison_core.py +357 -0
- parse_bench/analysis/comparison_report.py +2066 -0
- parse_bench/analysis/detailed_report.py +2254 -0
- parse_bench/analysis/leaderboard_report.py +852 -0
- parse_bench/analysis/metric_definitions.py +771 -0
- parse_bench/cli.py +267 -0
- parse_bench/data/__init__.py +1 -0
- parse_bench/data/cli.py +118 -0
- parse_bench/data/download.py +127 -0
- parse_bench/evaluation/__init__.py +11 -0
- parse_bench/evaluation/cli.py +435 -0
- parse_bench/evaluation/evaluators/__init__.py +17 -0
- parse_bench/evaluation/evaluators/base.py +34 -0
- parse_bench/evaluation/evaluators/extract.py +429 -0
- parse_bench/evaluation/evaluators/layoutdet.py +1682 -0
- parse_bench/evaluation/evaluators/parse.py +1353 -0
- parse_bench/evaluation/evaluators/qa.py +199 -0
- parse_bench/evaluation/layout_adapters/__init__.py +21 -0
- parse_bench/evaluation/layout_adapters/adapters.py +3180 -0
- parse_bench/evaluation/layout_adapters/base.py +105 -0
- parse_bench/evaluation/layout_adapters/registry.py +109 -0
- parse_bench/evaluation/layout_label_mappers/__init__.py +22 -0
- parse_bench/evaluation/layout_label_mappers/base.py +66 -0
- parse_bench/evaluation/layout_label_mappers/mappers.py +332 -0
- parse_bench/evaluation/layout_label_mappers/projection.py +74 -0
- parse_bench/evaluation/layout_label_mappers/registry.py +119 -0
- parse_bench/evaluation/metric_aggregation.py +56 -0
- parse_bench/evaluation/metrics/__init__.py +5 -0
- parse_bench/evaluation/metrics/attribution/__init__.py +35 -0
- parse_bench/evaluation/metrics/attribution/constants.py +12 -0
- parse_bench/evaluation/metrics/attribution/core.py +1108 -0
- parse_bench/evaluation/metrics/attribution/evaluate.py +446 -0
- parse_bench/evaluation/metrics/attribution/geometry.py +161 -0
- parse_bench/evaluation/metrics/attribution/text_utils.py +233 -0
- parse_bench/evaluation/metrics/base.py +33 -0
- parse_bench/evaluation/metrics/downstream/__init__.py +0 -0
- parse_bench/evaluation/metrics/extract/__init__.py +29 -0
- parse_bench/evaluation/metrics/extract/json_subset_match.py +473 -0
- parse_bench/evaluation/metrics/extract/json_subset_match_metric.py +81 -0
- parse_bench/evaluation/metrics/extract/list_unwrap.py +340 -0
- parse_bench/evaluation/metrics/extract/rule_based_metric.py +90 -0
- parse_bench/evaluation/metrics/extract/test_rules.py +409 -0
- parse_bench/evaluation/metrics/extract/test_types.py +11 -0
- parse_bench/evaluation/metrics/field_grounding/__init__.py +21 -0
- parse_bench/evaluation/metrics/field_grounding/core.py +437 -0
- parse_bench/evaluation/metrics/field_grounding/extract_adapter.py +1224 -0
- parse_bench/evaluation/metrics/field_grounding/parse_adapter.py +697 -0
- parse_bench/evaluation/metrics/field_grounding/rule_filters.py +19 -0
- parse_bench/evaluation/metrics/field_grounding/value_compare.py +190 -0
- parse_bench/evaluation/metrics/layoutdet/__init__.py +17 -0
- parse_bench/evaluation/metrics/layoutdet/classification_utils.py +300 -0
- parse_bench/evaluation/metrics/layoutdet/iou.py +76 -0
- parse_bench/evaluation/metrics/parse/__init__.py +5 -0
- parse_bench/evaluation/metrics/parse/_vendor_grits_reference.py +531 -0
- parse_bench/evaluation/metrics/parse/cross_page_table_consistency.py +165 -0
- parse_bench/evaluation/metrics/parse/emphasis_spans.py +242 -0
- parse_bench/evaluation/metrics/parse/fast_tree_edit.py +282 -0
- parse_bench/evaluation/metrics/parse/grits_metric.py +1125 -0
- parse_bench/evaluation/metrics/parse/grits_reference_metric.py +142 -0
- parse_bench/evaluation/metrics/parse/header_accuracy_metric.py +1662 -0
- parse_bench/evaluation/metrics/parse/llm_normalization/__init__.py +51 -0
- parse_bench/evaluation/metrics/parse/llm_normalization/base.py +125 -0
- parse_bench/evaluation/metrics/parse/llm_normalization/config.py +44 -0
- parse_bench/evaluation/metrics/parse/llm_normalization/postprocess.py +322 -0
- parse_bench/evaluation/metrics/parse/llm_normalization/strategy_judge.py +541 -0
- parse_bench/evaluation/metrics/parse/mermaid_graph.py +682 -0
- parse_bench/evaluation/metrics/parse/rule_based_judge_metric.py +56 -0
- parse_bench/evaluation/metrics/parse/rule_based_metric.py +434 -0
- parse_bench/evaluation/metrics/parse/rules_bag.py +1161 -0
- parse_bench/evaluation/metrics/parse/rules_base.py +751 -0
- parse_bench/evaluation/metrics/parse/rules_chart.py +1556 -0
- parse_bench/evaluation/metrics/parse/rules_diagram.py +591 -0
- parse_bench/evaluation/metrics/parse/rules_form.py +2274 -0
- parse_bench/evaluation/metrics/parse/rules_formatting.py +1500 -0
- parse_bench/evaluation/metrics/parse/rules_heading.py +228 -0
- parse_bench/evaluation/metrics/parse/rules_list.py +226 -0
- parse_bench/evaluation/metrics/parse/rules_page_decoration.py +276 -0
- parse_bench/evaluation/metrics/parse/rules_table.py +1666 -0
- parse_bench/evaluation/metrics/parse/rules_text.py +340 -0
- parse_bench/evaluation/metrics/parse/rules_watermark.py +105 -0
- parse_bench/evaluation/metrics/parse/structural_consistency_metric.py +251 -0
- parse_bench/evaluation/metrics/parse/table_extraction.py +152 -0
- parse_bench/evaluation/metrics/parse/table_merging.py +195 -0
- parse_bench/evaluation/metrics/parse/table_pairing.py +87 -0
- parse_bench/evaluation/metrics/parse/table_parsing.py +955 -0
- parse_bench/evaluation/metrics/parse/table_record_match_metric.py +1453 -0
- parse_bench/evaluation/metrics/parse/table_splitting.py +301 -0
- parse_bench/evaluation/metrics/parse/table_title_stripping.py +530 -0
- parse_bench/evaluation/metrics/parse/teds_metric.py +600 -0
- parse_bench/evaluation/metrics/parse/test_rules.py +120 -0
- parse_bench/evaluation/metrics/parse/test_types.py +103 -0
- parse_bench/evaluation/metrics/parse/text_content_projection.py +175 -0
- parse_bench/evaluation/metrics/parse/text_similarity_metric.py +61 -0
- parse_bench/evaluation/metrics/parse/utils.py +885 -0
- parse_bench/evaluation/metrics/qa/__init__.py +5 -0
- parse_bench/evaluation/metrics/qa/answer_comparison.py +380 -0
- parse_bench/evaluation/qa/__init__.py +5 -0
- parse_bench/evaluation/qa/llm_service.py +335 -0
- parse_bench/evaluation/reports/__init__.py +8 -0
- parse_bench/evaluation/reports/csv.py +64 -0
- parse_bench/evaluation/reports/html.py +338 -0
- parse_bench/evaluation/reports/markdown.py +98 -0
- parse_bench/evaluation/reports/rule_csv.py +22 -0
- parse_bench/evaluation/runner.py +1864 -0
- parse_bench/evaluation/stats.py +104 -0
- parse_bench/extensions.py +72 -0
- parse_bench/inference/__init__.py +33 -0
- parse_bench/inference/chunkr_layout_extraction.py +160 -0
- parse_bench/inference/cli.py +484 -0
- parse_bench/inference/layout_extraction.py +422 -0
- parse_bench/inference/pipelines/__init__.py +59 -0
- parse_bench/inference/pipelines/extract.py +39 -0
- parse_bench/inference/pipelines/layout.py +142 -0
- parse_bench/inference/pipelines/parse.py +2603 -0
- parse_bench/inference/pipelines.py +0 -0
- parse_bench/inference/providers/__init__.py +28 -0
- parse_bench/inference/providers/base.py +196 -0
- parse_bench/inference/providers/cancellation.py +137 -0
- parse_bench/inference/providers/extract/__init__.py +22 -0
- parse_bench/inference/providers/extract/citations.py +549 -0
- parse_bench/inference/providers/extract/extend.py +851 -0
- parse_bench/inference/providers/extract/llamaextract_v2_api.py +583 -0
- parse_bench/inference/providers/layoutdet/__init__.py +25 -0
- parse_bench/inference/providers/layoutdet/adapters.py +946 -0
- parse_bench/inference/providers/layoutdet/base.py +203 -0
- parse_bench/inference/providers/layoutdet/chandra.py +449 -0
- parse_bench/inference/providers/layoutdet/docling.py +125 -0
- parse_bench/inference/providers/layoutdet/dots_ocr.py +606 -0
- parse_bench/inference/providers/layoutdet/layout_v3.py +137 -0
- parse_bench/inference/providers/layoutdet/layout_v3_byoc.py +204 -0
- parse_bench/inference/providers/layoutdet/paddle.py +117 -0
- parse_bench/inference/providers/layoutdet/qwen3vl.py +360 -0
- parse_bench/inference/providers/layoutdet/surya.py +250 -0
- parse_bench/inference/providers/layoutdet/yolo.py +109 -0
- parse_bench/inference/providers/parse/__init__.py +64 -0
- parse_bench/inference/providers/parse/_docling_common.py +233 -0
- parse_bench/inference/providers/parse/_layout_utils.py +611 -0
- parse_bench/inference/providers/parse/amazon_nova.py +515 -0
- parse_bench/inference/providers/parse/anthropic.py +882 -0
- parse_bench/inference/providers/parse/azure_document_intelligence.py +700 -0
- parse_bench/inference/providers/parse/chandra2.py +633 -0
- parse_bench/inference/providers/parse/chunkr.py +268 -0
- parse_bench/inference/providers/parse/databricks_ai_parse.py +724 -0
- parse_bench/inference/providers/parse/datalab.py +370 -0
- parse_bench/inference/providers/parse/deepseekocr2.py +382 -0
- parse_bench/inference/providers/parse/docling.py +281 -0
- parse_bench/inference/providers/parse/docling_serve.py +289 -0
- parse_bench/inference/providers/parse/dots_ocr.py +574 -0
- parse_bench/inference/providers/parse/extend_parse.py +710 -0
- parse_bench/inference/providers/parse/falconocr.py +436 -0
- parse_bench/inference/providers/parse/florin_parser_nano.py +559 -0
- parse_bench/inference/providers/parse/gemma4.py +472 -0
- parse_bench/inference/providers/parse/glm_zai.py +229 -0
- parse_bench/inference/providers/parse/google.py +1125 -0
- parse_bench/inference/providers/parse/google_agentic_vision.py +819 -0
- parse_bench/inference/providers/parse/google_docai.py +776 -0
- parse_bench/inference/providers/parse/google_docai_layout_normalization.py +573 -0
- parse_bench/inference/providers/parse/granite_vision.py +515 -0
- parse_bench/inference/providers/parse/infinity_parser2.py +704 -0
- parse_bench/inference/providers/parse/kdl_frontier_nano.py +3327 -0
- parse_bench/inference/providers/parse/landingai.py +452 -0
- parse_bench/inference/providers/parse/liteparse.py +350 -0
- parse_bench/inference/providers/parse/llamaparse.py +677 -0
- parse_bench/inference/providers/parse/llamaparse_v2_normalization.py +1013 -0
- parse_bench/inference/providers/parse/markitdown.py +138 -0
- parse_bench/inference/providers/parse/mineru25.py +405 -0
- parse_bench/inference/providers/parse/mineru2605pro.py +432 -0
- parse_bench/inference/providers/parse/mineru_diffusion.py +371 -0
- parse_bench/inference/providers/parse/mistral_ocr.py +546 -0
- parse_bench/inference/providers/parse/nemotron_omni.py +473 -0
- parse_bench/inference/providers/parse/oi_parser.py +222 -0
- parse_bench/inference/providers/parse/openai.py +740 -0
- parse_bench/inference/providers/parse/opendataloader.py +152 -0
- parse_bench/inference/providers/parse/paddleocr.py +624 -0
- parse_bench/inference/providers/parse/pdf_inspector.py +142 -0
- parse_bench/inference/providers/parse/pulse.py +785 -0
- parse_bench/inference/providers/parse/pymupdf.py +207 -0
- parse_bench/inference/providers/parse/pymupdf4llm.py +356 -0
- parse_bench/inference/providers/parse/pypdf.py +179 -0
- parse_bench/inference/providers/parse/qwen.py +678 -0
- parse_bench/inference/providers/parse/rakedoc_nano.py +70 -0
- parse_bench/inference/providers/parse/reducto.py +546 -0
- parse_bench/inference/providers/parse/surya2.py +372 -0
- parse_bench/inference/providers/parse/tesseract.py +301 -0
- parse_bench/inference/providers/parse/textract.py +694 -0
- parse_bench/inference/providers/parse/unlimitedocr.py +346 -0
- parse_bench/inference/providers/parse/unstructured.py +485 -0
- parse_bench/inference/providers/parse/warp_ingest.py +199 -0
- parse_bench/inference/providers/registry.py +49 -0
- parse_bench/inference/renormalize.py +170 -0
- parse_bench/inference/runner.py +2023 -0
- parse_bench/layout_label_mapping.py +424 -0
- parse_bench/layout_projection.py +179 -0
- parse_bench/pipeline/__init__.py +1 -0
- parse_bench/pipeline/cli.py +549 -0
- parse_bench/schemas/__init__.py +33 -0
- parse_bench/schemas/evaluation.py +93 -0
- parse_bench/schemas/extract_output.py +36 -0
- parse_bench/schemas/layout_detection_output.py +545 -0
- parse_bench/schemas/layout_ontology.py +315 -0
- parse_bench/schemas/metrics.py +69 -0
- parse_bench/schemas/parse_output.py +152 -0
- parse_bench/schemas/pipeline.py +22 -0
- parse_bench/schemas/pipeline_io.py +106 -0
- parse_bench/schemas/product.py +97 -0
- parse_bench/test_cases/__init__.py +25 -0
- parse_bench/test_cases/bbox_value_strict_comparator.py +880 -0
- parse_bench/test_cases/extract_field_paths.py +164 -0
- parse_bench/test_cases/layout_attribution_generation.py +287 -0
- parse_bench/test_cases/loader.py +652 -0
- parse_bench/test_cases/parse_rule_schemas.py +1071 -0
- parse_bench/test_cases/rule_filters.py +32 -0
- parse_bench/test_cases/rule_ids.py +107 -0
- parse_bench/test_cases/schema.py +427 -0
- parse_bench/utils/__init__.py +15 -0
- parse_bench/utils/gemini_layout_utils.py +670 -0
- parse_bench/utils/text_aggregation.py +100 -0
- parse_bench-1.0.0.dist-info/METADATA +476 -0
- parse_bench-1.0.0.dist-info/RECORD +227 -0
- parse_bench-1.0.0.dist-info/WHEEL +4 -0
- parse_bench-1.0.0.dist-info/entry_points.txt +2 -0
- 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
|