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