1import re
2from typing import (
3 TYPE_CHECKING,
4 Any,
5 Dict,
6 List,
7 Match,
8 Optional,
9 Tuple,
10 Union,
11)
12
13if TYPE_CHECKING:
14 from ..block_parser import BlockParser
15 from ..core import BaseRenderer, BlockState
16 from ..markdown import Markdown
17
18# https://michelf.ca/projects/php-markdown/extra/#table
19
20__all__ = ["table", "table_in_quote", "table_in_list"]
21
22
23TABLE_PATTERN = r"^ {0,3}\|[^\n]*\|[ \t]*(?:\n|$)"
24NP_TABLE_PATTERN = r"^ {0,3}\S[^\n]*\|[^\n]*(?:\n|$)"
25
26ALIGN_CENTER = re.compile(r"^ *:-+: *$")
27ALIGN_LEFT = re.compile(r"^ *:-+ *$")
28ALIGN_RIGHT = re.compile(r"^ *-+: *$")
29ALIGN_NONE = re.compile(r"^ *-+ *$")
30
31
32def parse_table(block: "BlockParser", m: Match[str], state: "BlockState") -> Optional[int]:
33 pos = m.end()
34 header = _strip_pipe_table_row(m.group(0))
35 if header is None:
36 return None
37
38 align_line = state.get_line(pos)
39 align = _strip_pipe_table_row(align_line)
40 if align is None:
41 return None
42
43 thead, aligns = _process_thead(header, align)
44 if not thead:
45 return _parse_invalid_pipe_table(state, pos + len(align_line))
46 assert aligns is not None
47 pos += len(align_line)
48
49 rows = []
50 while pos < state.cursor_max:
51 line = state.get_line(pos)
52 text = _strip_pipe_table_row(line)
53 if text is None:
54 break
55
56 row = _process_row(text, aligns)
57 if not row:
58 return _parse_invalid_pipe_table(state, pos + len(line))
59 rows.append(row)
60 pos += len(line)
61
62 children = [thead, {"type": "table_body", "children": rows}]
63 state.append_token({"type": "table", "children": children})
64 return pos
65
66
67def parse_nptable(block: "BlockParser", m: Match[str], state: "BlockState") -> Optional[int]:
68 pos = m.end()
69 header = _strip_table_line(m.group(0))
70 if header is None:
71 return None
72
73 align_line = state.get_line(pos)
74 align = _strip_table_line(align_line)
75 if align is None:
76 return None
77
78 thead, aligns = _process_thead(header, align)
79 if not thead:
80 return None
81 assert aligns is not None
82 pos += len(align_line)
83
84 rows = []
85 while pos < state.cursor_max:
86 line = state.get_line(pos)
87 text = _strip_table_line(line)
88 if text is None:
89 break
90
91 row = _process_row(text, aligns)
92 if not row:
93 return None
94 rows.append(row)
95 pos += len(line)
96
97 children = [thead, {"type": "table_body", "children": rows}]
98 state.append_token({"type": "table", "children": children})
99 return pos
100
101
102def _process_thead(header: str, align: str) -> Union[Tuple[None, None], Tuple[Dict[str, Any], List[Optional[str]]]]:
103 headers = _split_table_cells(header)
104 raw_aligns = _split_table_cells(align)
105 if len(headers) != len(raw_aligns):
106 return None, None
107
108 aligns: List[Optional[str]] = []
109 for v in raw_aligns:
110 if ALIGN_CENTER.match(v):
111 aligns.append("center")
112 elif ALIGN_LEFT.match(v):
113 aligns.append("left")
114 elif ALIGN_RIGHT.match(v):
115 aligns.append("right")
116 elif ALIGN_NONE.match(v) or not v.strip():
117 aligns.append(None)
118 else:
119 # a delimiter cell must be dashes (optionally colon-flanked) or empty;
120 # anything else means this is not a delimiter row, so not a table
121 return None, None
122
123 children: List[Dict[str, Any]] = [
124 {"type": "table_cell", "text": text.strip(), "attrs": {"align": aligns[i], "head": True}}
125 for i, text in enumerate(headers)
126 ]
127 thead: Dict[str, Any] = {"type": "table_head", "children": children}
128 return thead, aligns
129
130
131def _process_row(text: str, aligns: List[Optional[str]]) -> Optional[Dict[str, Any]]:
132 cells = _split_table_cells(text)
133 if len(cells) != len(aligns):
134 return None
135
136 children: List[Dict[str, Any]] = [
137 {"type": "table_cell", "text": text.strip(), "attrs": {"align": aligns[i], "head": False}}
138 for i, text in enumerate(cells)
139 ]
140 return {"type": "table_row", "children": children}
141
142
143def _strip_pipe_table_row(line: str) -> Optional[str]:
144 text = line.rstrip("\n").rstrip(" \t")
145 if not text.startswith("|") and text.startswith((" ", "\t")):
146 text = text.lstrip(" ")
147 if not text.startswith("|") or not text.endswith("|"):
148 return None
149 return text[1:-1]
150
151
152def _parse_invalid_pipe_table(state: "BlockState", pos: int) -> int:
153 while pos < state.cursor_max:
154 line = state.get_line(pos)
155 if _strip_pipe_table_row(line) is None:
156 break
157 pos += len(line)
158 state.add_paragraph(state.src[state.cursor : pos])
159 return pos
160
161
162def _strip_table_line(line: str) -> Optional[str]:
163 text = line.rstrip("\n").rstrip(" \t")
164 if not text or "|" not in text:
165 return None
166 return text
167
168
169def _split_table_cells(text: str) -> List[str]:
170 cells = []
171 start = 0
172 pos = 0
173 while pos < len(text):
174 if text[pos] == "|" and not _is_escaped_pipe(text, pos):
175 cells.append(text[start:pos].strip())
176 start = pos + 1
177 pos += 1
178 cells.append(text[start:].strip())
179 return cells
180
181
182def _is_escaped_pipe(text: str, pos: int) -> bool:
183 backslashes = 0
184 pos -= 1
185 while pos >= 0 and text[pos] == "\\":
186 backslashes += 1
187 pos -= 1
188 return backslashes % 2 == 1
189
190
191def render_table(renderer: "BaseRenderer", text: str) -> str:
192 return "<table>\n" + text + "</table>\n"
193
194
195def render_table_head(renderer: "BaseRenderer", text: str) -> str:
196 return "<thead>\n<tr>\n" + text + "</tr>\n</thead>\n"
197
198
199def render_table_body(renderer: "BaseRenderer", text: str) -> str:
200 return "<tbody>\n" + text + "</tbody>\n"
201
202
203def render_table_row(renderer: "BaseRenderer", text: str) -> str:
204 return "<tr>\n" + text + "</tr>\n"
205
206
207def render_table_cell(renderer: "BaseRenderer", text: str, align: Optional[str] = None, head: bool = False) -> str:
208 if head:
209 tag = "th"
210 else:
211 tag = "td"
212
213 html = " <" + tag
214 if align:
215 html += ' style="text-align:' + align + '"'
216
217 return html + ">" + text + "</" + tag + ">\n"
218
219
220def table(md: "Markdown") -> None:
221 """A mistune plugin to support table, spec defined at
222 https://michelf.ca/projects/php-markdown/extra/#table
223
224 Here is an example:
225
226 .. code-block:: text
227
228 First Header | Second Header
229 ------------- | -------------
230 Content Cell | Content Cell
231 Content Cell | Content Cell
232
233 :param md: Markdown instance
234 """
235 md.block.register("table", TABLE_PATTERN, parse_table, before="paragraph")
236 md.block.register("nptable", NP_TABLE_PATTERN, parse_nptable, before="paragraph")
237
238 if md.renderer and md.renderer.NAME == "html":
239 md.renderer.register("table", render_table)
240 md.renderer.register("table_head", render_table_head)
241 md.renderer.register("table_body", render_table_body)
242 md.renderer.register("table_row", render_table_row)
243 md.renderer.register("table_cell", render_table_cell)
244
245
246def table_in_quote(md: "Markdown") -> None:
247 """Enable table plugin in block quotes."""
248 md.block.insert_rule(md.block.block_quote_rules, "table", before="paragraph")
249 md.block.insert_rule(md.block.block_quote_rules, "nptable", before="paragraph")
250
251
252def table_in_list(md: "Markdown") -> None:
253 """Enable table plugin in list."""
254 md.block.insert_rule(md.block.list_rules, "table", before="paragraph")
255 md.block.insert_rule(md.block.list_rules, "nptable", before="paragraph")