1from __future__ import annotations
2
3import re
4from dataclasses import dataclass
5from typing import Any, Dict, List, Tuple, cast
6
7_CHARREF_PREFIX = re.compile(r"(#[0-9]{1,7};|#[xX][0-9a-fA-F]+;|[^\t\n\f <&#;]{1,32};)")
8
9
10def is_entity_boundary(left: str, right: str) -> bool:
11 return left.endswith("&") and _CHARREF_PREFIX.match(right) is not None
12
13
14@dataclass
15class _Delimiter:
16 index: int
17 marker: str
18 length: int
19 can_open: bool
20 can_close: bool
21 #: original run length, used by the CommonMark multiple-of-3 rule even
22 #: after the run has been partially consumed
23 orig_length: int = 0
24 order: int = 0
25
26
27class _DelimiterIndex:
28 """Track part positions without rewriting every delimiter after a splice."""
29
30 def __init__(self, delimiters: List[_Delimiter], part_count: int) -> None:
31 self._tree = [0] * (part_count + 1)
32 self._next = list(range(len(delimiters) + 1))
33
34 def current(self, delimiter: _Delimiter) -> int:
35 index = delimiter.index
36 total = 0
37 cursor = index + 1
38 while cursor:
39 total += self._tree[cursor]
40 cursor -= cursor & -cursor
41 return index - total
42
43 def collapse(self, closer: _Delimiter, removed: int) -> None:
44 if not removed:
45 return
46 cursor = closer.index + 1
47 while cursor < len(self._tree):
48 self._tree[cursor] += removed
49 cursor += cursor & -cursor
50
51 def deactivate(self, order: int) -> None:
52 self._next[order] = self._find(order + 1)
53
54 def deactivate_range(self, delimiters: List[_Delimiter], start: int, end: int) -> None:
55 order = self._find(start)
56 while order < end:
57 delimiters[order].length = 0
58 self._next[order] = self._find(order + 1)
59 order = self._find(order)
60
61 def _find(self, order: int) -> int:
62 root = order
63 while self._next[root] != root:
64 root = self._next[root]
65 while self._next[order] != order:
66 parent = self._next[order]
67 self._next[order] = root
68 order = parent
69 return root
70
71
72def finalize_emphasis_tokens(
73 tokens: List[Dict[str, Any]],
74 enabled: bool,
75 max_depth: int,
76) -> List[Dict[str, Any]]:
77 if not enabled:
78 return _clean_emphasis_tokens(tokens)
79 if not _contains_emphasis_marker(tokens):
80 return _clean_emphasis_tokens(tokens)
81
82 parts: List[Dict[str, Any]] = []
83 delimiters: List[_Delimiter] = []
84 source = _emphasis_source_text(tokens)
85 source_pos = 0
86 for token in tokens:
87 if token["type"] == "text" and token.get("_emphasis", True):
88 _split_text_token(token, source, source_pos, parts, delimiters)
89 else:
90 parts.append(_clean_emphasis_token(token))
91 source_pos += _emphasis_source_length(token)
92
93 if _process_dense_emphasis(parts, delimiters):
94 return _merge_text_tokens(parts)
95
96 _process_emphasis_delimiters(parts, delimiters, max_depth)
97 return _merge_text_tokens(parts)
98
99
100def _process_dense_emphasis(parts: List[Dict[str, Any]], delimiters: List[_Delimiter]) -> bool:
101 """Process a flat run such as ``*a*a*a`` without repeated list scans."""
102 if len(delimiters) < 4:
103 return False
104
105 marker = delimiters[0].marker
106 if marker not in ("*", "_") or len(parts) != len(delimiters) * 2:
107 return False
108
109 for index, delimiter in enumerate(delimiters):
110 if (
111 delimiter.marker != marker
112 or delimiter.length != 1
113 or delimiter.index != index * 2
114 or parts[delimiter.index]["type"] != "text"
115 or parts[delimiter.index + 1]["type"] != "text"
116 ):
117 return False
118
119 pair_count = len(delimiters) // 2
120 processed: List[Dict[str, Any]] = []
121 for pair_index in range(pair_count):
122 opener = delimiters[pair_index * 2]
123 closer = delimiters[pair_index * 2 + 1]
124 if (
125 not opener.can_open
126 or not closer.can_close
127 or not _can_match_emphasis_delimiters(opener, closer)
128 or not _has_emphasis_content(parts, opener.index + 1, closer.index)
129 ):
130 return False
131
132 if pair_index:
133 processed.append(parts[opener.index - 1])
134 processed.append(
135 {
136 "type": "emphasis",
137 "children": [parts[opener.index + 1]],
138 }
139 )
140
141 if pair_count:
142 last_close = delimiters[pair_count * 2 - 1].index
143 parts[:] = processed + parts[last_close + 1 :]
144 return True
145
146
147def _contains_emphasis_marker(tokens: List[Dict[str, Any]]) -> bool:
148 for token in tokens:
149 if token["type"] == "text" and token.get("_emphasis", True):
150 raw = token["raw"]
151 if "*" in raw or "_" in raw:
152 return True
153 return False
154
155
156def _clean_emphasis_tokens(tokens: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
157 return [_clean_emphasis_token(token) for token in tokens]
158
159
160def _clean_emphasis_token(token: Dict[str, Any]) -> Dict[str, Any]:
161 if "_emphasis" not in token:
162 return token
163 token = token.copy()
164 token.pop("_emphasis", None)
165 return token
166
167
168def _emphasis_source_text(tokens: List[Dict[str, Any]]) -> str:
169 values = []
170 for token in tokens:
171 if token["type"] == "text":
172 values.append(token["raw"])
173 elif token["type"] in ("softbreak", "linebreak"):
174 values.append("\n")
175 else:
176 values.append("\ufffc")
177 return "".join(values)
178
179
180def _emphasis_source_length(token: Dict[str, Any]) -> int:
181 if token["type"] == "text":
182 return len(token["raw"])
183 return 1
184
185
186def _split_text_token(
187 token: Dict[str, Any],
188 source: str,
189 source_start: int,
190 parts: List[Dict[str, Any]],
191 delimiters: List[_Delimiter],
192) -> None:
193 text = token["raw"]
194 pos = 0
195 while pos < len(text):
196 if text[pos] not in "*_":
197 end = _next_delimiter_run(text, pos)
198 parts.append({"type": "text", "raw": text[pos:end]})
199 pos = end
200 continue
201
202 marker = text[pos]
203 end = pos
204 while end < len(text) and text[end] == marker:
205 end += 1
206 length = end - pos
207 absolute = source_start + pos
208 can_open = _can_open_emphasis(source, absolute, length, marker)
209 can_close = _can_close_emphasis(source, absolute, length, marker)
210 index = len(parts)
211 parts.append({"type": "text", "raw": text[pos:end]})
212 if can_open or can_close:
213 delimiters.append(_Delimiter(index, marker, length, can_open, can_close, length, len(delimiters)))
214 pos = end
215
216
217def _next_delimiter_run(text: str, pos: int) -> int:
218 while pos < len(text) and text[pos] not in "*_":
219 pos += 1
220 return pos
221
222
223def _process_emphasis_delimiters(
224 parts: List[Dict[str, Any]],
225 delimiters: List[_Delimiter],
226 max_depth: int,
227) -> None:
228 index_map = _DelimiterIndex(delimiters, len(parts))
229 closer_pos = 0
230 openers_bottom: Dict[Tuple[str, int, bool], int] = {}
231 while closer_pos < len(delimiters):
232 closer = delimiters[closer_pos]
233 if not closer.can_close or closer.length == 0:
234 closer_pos += 1
235 continue
236
237 opener_key = (closer.marker, closer.length % 3, closer.can_open)
238 opener_pos = closer_pos - 1
239 opener_bottom = openers_bottom.get(opener_key, 0)
240 opener = None
241 while opener_pos >= opener_bottom:
242 candidate = delimiters[opener_pos]
243 if (
244 candidate.marker == closer.marker
245 and candidate.can_open
246 and candidate.length > 0
247 and _can_match_emphasis_delimiters(candidate, closer)
248 ):
249 opener = candidate
250 break
251 opener_pos -= 1
252
253 if opener is None:
254 openers_bottom[opener_key] = closer_pos
255 closer_pos += 1
256 continue
257
258 opener_index = index_map.current(opener)
259 closer_index = index_map.current(closer)
260 if opener.length >= 2 and closer.length >= 2:
261 use_length = 2
262 else:
263 use_length = 1
264 if use_length == 2 and not _has_strong_enabled(parts, opener_index, closer_index):
265 use_length = 1
266 if use_length == 1 and not _has_emphasis_enabled(parts, opener_index, closer_index):
267 closer_pos += 1
268 continue
269 if not _has_emphasis_content(parts, opener_index + 1, closer_index):
270 closer_pos += 1
271 continue
272
273 opener_text = parts[opener_index]
274 closer_text = parts[closer_index]
275 if opener_text["type"] != "text" or closer_text["type"] != "text":
276 closer_pos += 1
277 continue
278
279 children = parts[opener_index + 1 : closer_index]
280 if max_depth > 0 and _emphasis_depth(children) >= max_depth:
281 closer_pos += 1
282 continue
283 opener_text["raw"] = opener_text["raw"][:-use_length]
284 closer_text["raw"] = closer_text["raw"][use_length:]
285 if use_length == 2:
286 node = {"type": "strong", "children": children}
287 else:
288 node = {"type": "emphasis", "children": children}
289
290 old_closer_index = closer_index
291 parts[opener_index + 1 : old_closer_index] = [node]
292
293 removed = old_closer_index - opener_index - 2
294 if removed:
295 index_map.deactivate_range(delimiters, opener_pos + 1, closer_pos)
296 index_map.collapse(closer, removed)
297
298 opener.length -= use_length
299 closer.length -= use_length
300 if opener.length == 0:
301 opener.can_open = False
302 index_map.deactivate(opener.order)
303 if closer.length == 0:
304 closer.can_close = False
305 index_map.deactivate(closer.order)
306
307 if opener.can_open or closer.can_close:
308 closer_pos = max(opener_pos, openers_bottom.get(opener_key, 0))
309 else:
310 closer_pos += 1
311
312
313def _emphasis_depth(tokens: List[Dict[str, Any]]) -> int:
314 max_depth = 0
315 stack = [(token, 0) for token in tokens]
316 while stack:
317 token, depth = stack.pop()
318 token_type = token["type"]
319 if token_type in ("emphasis", "strong"):
320 depth += 1
321 if depth > max_depth:
322 max_depth = depth
323 for child in token.get("children", ()):
324 stack.append((child, depth))
325 return max_depth
326
327
328def _has_strong_enabled(parts: List[Dict[str, Any]], opener_index: int, closer_index: int) -> bool:
329 return len(_text_raw(parts[opener_index])) >= 2 and len(_text_raw(parts[closer_index])) >= 2
330
331
332def _has_emphasis_enabled(parts: List[Dict[str, Any]], opener_index: int, closer_index: int) -> bool:
333 return bool(_text_raw(parts[opener_index]) and _text_raw(parts[closer_index]))
334
335
336def _text_raw(token: Dict[str, Any]) -> str:
337 if token["type"] == "text":
338 return cast(str, token["raw"])
339 return ""
340
341
342def _has_emphasis_content(parts: List[Dict[str, Any]], start: int, end: int) -> bool:
343 for part in parts[start:end]:
344 if part["type"] != "text" or part["raw"] != "":
345 return True
346 return False
347
348
349def _can_match_emphasis_delimiters(opener: _Delimiter, closer: _Delimiter) -> bool:
350 if opener.can_close or closer.can_open:
351 open_len = opener.orig_length
352 close_len = closer.orig_length
353 return (open_len + close_len) % 3 != 0 or open_len % 3 == 0 and close_len % 3 == 0
354 return True
355
356
357def _can_open_emphasis(text: str, start: int, size: int, marker: str) -> bool:
358 previous = text[start - 1] if start > 0 else "\n"
359 next_char = text[start + size] if start + size < len(text) else "\n"
360 if marker == "_" and previous.isalnum() and next_char.isalnum():
361 return False
362 if next_char.isspace():
363 return False
364 if _is_punctuation(next_char) and not previous.isspace() and not _is_punctuation(previous):
365 return False
366 return True
367
368
369def _can_close_emphasis(text: str, start: int, size: int, marker: str) -> bool:
370 previous = text[start - 1] if start > 0 else "\n"
371 next_char = text[start + size] if start + size < len(text) else "\n"
372 if marker == "_" and previous.isalnum() and next_char.isalnum():
373 return False
374 if previous.isspace():
375 return False
376 if _is_punctuation(previous) and not next_char.isspace() and not _is_punctuation(next_char):
377 return False
378 return True
379
380
381def _is_punctuation(char: str) -> bool:
382 return not char.isspace() and not char.isalnum()
383
384
385def _merge_text_tokens(tokens: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
386 result: List[Dict[str, Any]] = []
387 for token in tokens:
388 if token["type"] == "text" and token["raw"] == "":
389 continue
390 if token["type"] == "text" and result and result[-1]["type"] == "text":
391 if not is_entity_boundary(result[-1]["raw"], token["raw"]):
392 result[-1]["raw"] += token["raw"]
393 continue
394 result.append(_clean_emphasis_token(token))
395 return result