Skip to content

Commit ae7e9d5

Browse files
committed
perf: improve for footnotes, ruby and spoiler
1 parent c2228a2 commit ae7e9d5

4 files changed

Lines changed: 68 additions & 18 deletions

File tree

‎src/mistune/plugins/footnotes.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,15 @@ def parse_inline_footnote(inline: "InlineParser", m: Match[str], state: "InlineS
3434
notes = state.env.get("footnotes")
3535
if not notes:
3636
notes = []
37+
indexes = state.env.get("footnote_indexes")
38+
if not indexes:
39+
indexes = {note_key: index for index, note_key in enumerate(notes)}
40+
state.env["footnote_indexes"] = indexes
3741
if key not in notes:
3842
notes.append(key)
43+
indexes[key] = len(notes) - 1
3944
state.env["footnotes"] = notes
40-
state.append_token({"type": "footnote_ref", "raw": key, "attrs": {"index": notes.index(key) + 1}})
45+
state.append_token({"type": "footnote_ref", "raw": key, "attrs": {"index": indexes[key] + 1}})
4146
else:
4247
state.append_token({"type": "text", "raw": m.group(0)})
4348
return m.end()

‎src/mistune/plugins/ruby.py‎

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,20 +15,15 @@
1515

1616

1717
def parse_ruby(inline: "InlineParser", m: Match[str], state: "InlineState") -> int:
18-
text = m.group(0)[1:-2]
19-
items = text.split(")")
20-
tokens = []
21-
for item in items:
22-
rb, rt = item.split("(")
23-
tokens.append({"type": "ruby", "raw": rb, "attrs": {"rt": rt}})
24-
25-
end_pos = m.end()
26-
27-
next_match = _ruby_re.match(state.src, end_pos)
28-
if next_match:
18+
while True:
19+
tokens = _parse_ruby_tokens(m)
20+
end_pos = m.end()
21+
next_match = _ruby_re.match(state.src, end_pos)
22+
if not next_match:
23+
break
2924
for tok in tokens:
3025
state.append_token(tok)
31-
return parse_ruby(inline, next_match, state)
26+
m = next_match
3227

3328
# repeat link logic
3429
if end_pos < len(state.src):
@@ -41,6 +36,15 @@ def parse_ruby(inline: "InlineParser", m: Match[str], state: "InlineState") -> i
4136
return end_pos
4237

4338

39+
def _parse_ruby_tokens(m: Match[str]) -> List[Dict[str, Any]]:
40+
text = m.group(0)[1:-2]
41+
tokens = []
42+
for item in text.split(")"):
43+
rb, rt = item.split("(")
44+
tokens.append({"type": "ruby", "raw": rb, "attrs": {"rt": rt}})
45+
return tokens
46+
47+
4448
def _parse_ruby_link(
4549
inline: "InlineParser", state: "InlineState", pos: int, tokens: List[Dict[str, Any]]
4650
) -> Optional[int]:

‎src/mistune/plugins/spoiler.py‎

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import re
2-
from typing import TYPE_CHECKING, Match
2+
from typing import TYPE_CHECKING, Match, Optional
33

44
if TYPE_CHECKING:
55
from ..block_parser import BlockParser
@@ -12,7 +12,8 @@
1212
_BLOCK_SPOILER_START = re.compile(r"^ {0,3}! ?", re.M)
1313
_BLOCK_SPOILER_MATCH = re.compile(r"^( {0,3}![^\n]*\n)+$")
1414

15-
INLINE_SPOILER_PATTERN = r">!\s*(?P<spoiler_text>.+?)\s*!<"
15+
INLINE_SPOILER_PATTERN = r">!"
16+
_INLINE_SPOILER_END = re.compile(r"\s*(?P<spoiler_text>.+?)\s*!<")
1617

1718

1819
def parse_block_spoiler(block: "BlockParser", m: Match[str], state: "BlockState") -> int:
@@ -46,13 +47,28 @@ def parse_block_spoiler(block: "BlockParser", m: Match[str], state: "BlockState"
4647
return state.cursor
4748

4849

49-
def parse_inline_spoiler(inline: "InlineParser", m: Match[str], state: "InlineState") -> int:
50-
text = m.group("spoiler_text")
50+
def parse_inline_spoiler(inline: "InlineParser", m: Match[str], state: "InlineState") -> Optional[int]:
51+
pos = m.end()
52+
cache_key = (id(state.src), "inline_spoiler")
53+
cache = state.formatting_no_end.get(cache_key)
54+
if cache is not None and cache[0] is state.src and pos <= cache[1]:
55+
return None
56+
57+
line_end = state.src.find("\n", pos)
58+
if line_end == -1:
59+
line_end = len(state.src)
60+
61+
m2 = _INLINE_SPOILER_END.match(state.src, pos, line_end)
62+
if not m2:
63+
state.formatting_no_end[cache_key] = (state.src, line_end)
64+
return None
65+
66+
text = m2.group("spoiler_text")
5167
new_state = state.copy()
5268
new_state.src = text
5369
children = inline.render(new_state)
5470
state.append_token({"type": "inline_spoiler", "children": children})
55-
return m.end()
71+
return m2.end()
5672

5773

5874
def render_block_spoiler(renderer: "BaseRenderer", text: str) -> str:

‎tests/test_security_inline.py‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,3 +129,28 @@ def render(size):
129129

130130
self.assertLess(large, DEADLINE)
131131
self.assertLess(large, small * 3)
132+
133+
def test_repeated_unclosed_inline_spoilers_are_near_linear(self):
134+
md = create_markdown(plugins=["spoiler"])
135+
136+
def render(size):
137+
text = ">! a " * size + "\n"
138+
start = time.process_time()
139+
md(text)
140+
return time.process_time() - start
141+
142+
render(200)
143+
small = render(1000)
144+
large = render(2000)
145+
146+
self.assertLess(large, DEADLINE)
147+
self.assertLess(large, small * 3)
148+
149+
def test_adjacent_ruby_tokens_do_not_recurse_forever(self):
150+
text = "[a(b)]" * 3000 + "\n"
151+
152+
start = time.monotonic()
153+
create_markdown(plugins=["ruby"])(text)
154+
elapsed = time.monotonic() - start
155+
156+
self.assertLess(elapsed, DEADLINE)

0 commit comments

Comments
 (0)