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