table_parser.py
7.11 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
"""Markdown table parser for Hermes Feishu plugin.
Parses Markdown table syntax into structured data suitable for building
Feishu card Table components.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Any, List, Optional, Tuple
# Regex to detect a Markdown table block.
# Matches lines like: | header1 | header2 | followed by | --- | --- | and data rows.
# Note: This regex is used within blank-line-split sections to avoid merging
# separate tables. See parse_table() for the splitting logic.
_TABLE_BLOCK_RE = re.compile(
r"((?:^\|[^\n]+\|\s*\n"
r"^\|(\s*:?-+:?\s*\|)+\s*\n?"
r"(?:^\|[^\n]+\|\s*\n?)*)+)",
re.MULTILINE,
)
# Blank line separator for splitting independent table sections.
_BLANK_LINE_RE = re.compile(r"\n[ \t]*\n")
# Match a single table row: | cell1 | cell2 |
_ROW_RE = re.compile(r"^\|(.+)\|$", re.MULTILINE)
# Match the separator row: | --- | :---: | ---: |
# Each cell must contain at least one dash (-) or colon (:)
_SEPARATOR_RE = re.compile(r"^\|(\s*:?-+:?\s*\|)+\s*$", re.MULTILINE)
@dataclass
class TableCell:
"""A single cell in a parsed table."""
text: str
raw: str = ""
def __post_init__(self):
if not self.raw:
self.raw = self.text
@dataclass
class TableColumn:
"""A column definition for a parsed table."""
name: str
index: int
field_type: str = "text" # "text" | "number"
width: Optional[int] = None
@dataclass
class ParsedTable:
"""Result of parsing a Markdown table block."""
headers: List[TableColumn] = field(default_factory=list)
rows: List[List[TableCell]] = field(default_factory=list)
raw_markdown: str = ""
def _parse_row(line: str) -> List[str]:
"""Parse a single table row into cell strings."""
line = line.strip()
if not line.startswith("|") or not line.endswith("|"):
return []
# Remove leading and trailing pipe, split by pipe
inner = line[1:-1]
cells = [cell.strip() for cell in inner.split("|")]
return cells
def _infer_column_type(values: List[str]) -> str:
"""Infer the column type from its values.
Returns "number" if all non-empty values are numeric, otherwise "text".
"""
non_empty = [v for v in values if v.strip()]
if not non_empty:
return "text"
for v in non_empty:
# Remove common formatting (%, commas, etc.) and try to convert
cleaned = v.replace(",", "").replace("%", "").strip()
try:
float(cleaned)
except ValueError:
return "text"
return "number"
def parse_table(markdown: str) -> List[ParsedTable]:
"""Parse all Markdown table blocks from the given text.
Splits input by blank lines first to avoid merging separate tables,
then applies the table regex within each section.
Args:
markdown: Text that may contain one or more Markdown tables.
Returns:
List of ParsedTable objects, one per table block found.
"""
tables: List[ParsedTable] = []
# Pre-split by blank lines so independent tables are parsed separately
sections = _BLANK_LINE_RE.split(markdown)
for section in sections:
if not section.strip():
continue
for match in _TABLE_BLOCK_RE.finditer(section):
block = match.group(1)
lines = [l for l in block.split("\n") if l.strip()]
if len(lines) < 2:
continue # Need at least header + separator
# Parse header row (first line)
header_cells = _parse_row(lines[0])
if not header_cells:
continue
# Verify separator row (second line)
if not _SEPARATOR_RE.match(lines[1].strip()):
continue
# Build column definitions
columns: List[TableColumn] = []
for idx, name in enumerate(header_cells):
columns.append(TableColumn(name=name, index=idx))
# Parse data rows (lines after separator)
data_rows: List[List[TableCell]] = []
all_column_values: dict[int, List[str]] = {col.index: [] for col in columns}
for line in lines[2:]:
if _SEPARATOR_RE.match(line.strip()):
continue
cells = _parse_row(line)
if not cells:
continue
row_cells: List[TableCell] = []
for idx, cell_text in enumerate(cells):
if idx < len(columns):
tc = TableCell(text=cell_text)
row_cells.append(tc)
all_column_values[idx].append(cell_text)
else:
# Extra columns beyond header: append as text
tc = TableCell(text=cell_text)
row_cells.append(tc)
data_rows.append(row_cells)
# Infer column types
for col in columns:
col.field_type = _infer_column_type(all_column_values.get(col.index, []))
tables.append(ParsedTable(
headers=columns,
rows=data_rows,
raw_markdown=block.strip(),
))
return tables
def contains_table(markdown: str) -> bool:
"""Check if the text contains any Markdown table syntax.
Args:
markdown: Text to check.
Returns:
True if at least one table block is detected.
"""
for section in _BLANK_LINE_RE.split(markdown):
if _TABLE_BLOCK_RE.search(section):
return True
return False
def split_table_and_text(markdown: str) -> Tuple[List[str], List[str]]:
"""Split markdown into table blocks and non-table text segments.
Splits by blank lines first, then within each section separates
text from tables using regex match positions.
Args:
markdown: Text that may contain tables and other content.
Returns:
Tuple of (table_blocks, text_segments).
table_blocks: Raw markdown strings of each table found.
text_segments: Non-table text portions, in original order.
"""
table_blocks: List[str] = []
text_segments: List[str] = []
sections = _BLANK_LINE_RE.split(markdown)
for section in sections:
if not section.strip():
continue
# Check if section contains a table
match = _TABLE_BLOCK_RE.search(section)
if not match:
stripped = section.strip()
if stripped:
text_segments.append(stripped)
continue
# Extract text before the first table in this section
before = section[:match.start()].strip()
if before:
text_segments.append(before)
# Collect all tables in this section (may be merged by regex)
for t in parse_table(section):
table_blocks.append(t.raw_markdown)
# Extract text after the last table in this section
last_end = 0
for m in _TABLE_BLOCK_RE.finditer(section):
last_end = m.end()
after = section[last_end:].strip()
if after:
text_segments.append(after)
return table_blocks, text_segments