Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/markdown/htmlparser.py: 99%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1# Python Markdown
3# A Python implementation of John Gruber's Markdown.
5# Documentation: https://python-markdown.github.io/
6# GitHub: https://github.com/Python-Markdown/markdown/
7# PyPI: https://pypi.org/project/Markdown/
9# Started by Manfred Stienstra (http://www.dwerg.net/).
10# Maintained for a few years by Yuri Takhteyev (http://www.freewisdom.org).
11# Currently maintained by Waylan Limberg (https://github.com/waylan),
12# Dmitry Shachnev (https://github.com/mitya57) and Isaac Muse (https://github.com/facelessuser).
14# Copyright 2007-2023 The Python Markdown Project (v. 1.7 and later)
15# Copyright 2004, 2005, 2006 Yuri Takhteyev (v. 0.2-1.6b)
16# Copyright 2004 Manfred Stienstra (the original version)
18# License: BSD (see LICENSE.md for details).
20"""
21This module imports a copy of [`html.parser.HTMLParser`][] and modifies it heavily through monkey-patches.
22A copy is imported rather than the module being directly imported as this ensures that the user can import
23and use the unmodified library for their own needs.
24"""
26from __future__ import annotations
28import re
29import importlib.util
30import sys
31from typing import TYPE_CHECKING, Sequence
33if TYPE_CHECKING: # pragma: no cover
34 from markdown import Markdown
36# Included for versions which do not have current comment fix
37commentclose = re.compile(r'--!?>')
39# Import a copy of the html.parser lib as `htmlparser` so we can monkeypatch it.
40# Users can still do `from html import parser` and get the default behavior.
41spec = importlib.util.find_spec('html.parser')
42htmlparser = importlib.util.module_from_spec(spec)
43spec.loader.exec_module(htmlparser)
44sys.modules['htmlparser'] = htmlparser
46# This is a hack. We are sneaking in `</>` so we can capture it without the HTML parser
47# throwing it away. When we see it, we will process it as data.
48htmlparser.starttagopen = re.compile('<[a-zA-Z]|</>')
50htmlparser.endtagopen = re.compile('</[a-zA-Z]?')
52# Monkeypatch `HTMLParser` to only accept `?>` to close Processing Instructions.
53htmlparser.piclose = re.compile(r'\?>')
54# Monkeypatch `HTMLParser` to only recognize entity references with a closing semicolon.
55htmlparser.entityref = re.compile(r'&([a-zA-Z][-.a-zA-Z0-9]*);')
56# Monkeypatch `HTMLParser` to no longer support partial entities. We are always feeding a complete block,
57# so the 'incomplete' functionality is unnecessary. As the `entityref` regex is run right before incomplete,
58# and the two regex are the same, then incomplete will simply never match and we avoid the logic within.
59htmlparser.incomplete = htmlparser.entityref
60# Monkeypatch `HTMLParser` to not accept a backtick in a tag name, attribute name, or bare value.
61htmlparser.locatestarttagend_tolerant = re.compile(r"""
62 <[a-zA-Z][^`\t\n\r\f />\x00]* # tag name <= added backtick here
63 (?:[\s/]* # optional whitespace before attribute name
64 (?:(?<=['"\s/])[^`\s/>][^\s/=>]* # attribute name <= added backtick here
65 (?:\s*=+\s* # value indicator
66 (?:'[^']*' # LITA-enclosed value
67 |"[^"]*" # LIT-enclosed value
68 |(?!['"])[^`>\s]* # bare value <= added backtick here
69 )
70 (?:\s*,)* # possibly followed by a comma
71 )?(?:\s|/(?!>))*
72 )*
73 )?
74 \s* # trailing whitespace
75""", re.VERBOSE)
76htmlparser.locatetagend = re.compile(r"""
77 [a-zA-Z][^`\t\n\r\f />]* # tag name
78 [\t\n\r\f /]* # optional whitespace before attribute name
79 (?:(?<=['"\t\n\r\f /])[^`\t\n\r\f />][^\t\n\r\f /=>]* # attribute name
80 (?:= # value indicator
81 (?:'[^']*' # LITA-enclosed value
82 |"[^"]*" # LIT-enclosed value
83 |(?!['"])[^>\t\n\r\f ]* # bare value
84 )
85 )?
86 [\t\n\r\f /]* # possibly followed by a space
87 )*
88 >?
89""", re.VERBOSE)
91# Match a blank line at the start of a block of text (two newlines).
92# The newlines may be preceded by additional whitespace.
93blank_line_re = re.compile(r'^([ ]*\n){2}')
95# Match a blank line anywhere in a run of text.
96inner_blank_line_re = re.compile(r'\n[ \t]*\n')
98# Elements which never have an end tag and so cannot contain a comment.
99void_tags = frozenset([
100 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr'
101])
104class _HTMLParser(htmlparser.HTMLParser):
105 """Handle special start and end tags."""
107 def parse_endtag(self, i):
108 start = self.rawdata[i:i+3]
109 c = ord(start[-1])
110 is_data = len(start) < 3 or not (65 <= c <= 90 or 97 <= c <= 122)
111 pos = super().parse_endtag(i) if not is_data else -1
112 if pos == -1:
113 self.handle_data(self.rawdata[i:i + 2])
114 return i + 2
115 return pos
117 def parse_starttag(self, i: int) -> int: # pragma: no cover
118 # Treat `</>` as normal data as it is not a real tag.
119 if self.rawdata[i:i + 3] == '</>':
120 self.handle_data(self.rawdata[i:i + 3])
121 return i + 3
123 return super().parse_starttag(i)
126# Overwrite our custom one for people like MkDocs that pull it in
127htmlparser.HTMLParser = _HTMLParser
130class HTMLExtractor(htmlparser.HTMLParser):
131 """
132 Extract raw HTML from text.
134 The raw HTML is stored in the [`htmlStash`][markdown.util.HtmlStash] of the
135 [`Markdown`][markdown.Markdown] instance passed to `md` and the remaining text
136 is stored in `cleandoc` as a list of strings.
137 """
139 def __init__(self, md: Markdown, *args, **kwargs):
140 if 'convert_charrefs' not in kwargs:
141 kwargs['convert_charrefs'] = False
143 # Block tags that should contain no content (self closing)
144 self.empty_tags = set(['hr'])
146 self.lineno_start_cache = [0]
148 # This calls self.reset
149 super().__init__(*args, **kwargs)
150 self.md = md
152 def reset(self):
153 """Reset this instance. Loses all unprocessed data."""
154 self.inraw = False
155 self.intail = False
156 self.stack: list[str] = [] # When `inraw==True`, stack contains a list of tags
157 self.inline_stack: list[str] = [] # Unclosed raw inline tags in the current paragraph
158 self._cache: list[str] = []
159 self.cleandoc: list[str] = []
160 self.lineno_start_cache = [0]
161 # The `rawdata` and position of the last failed search for a comment close.
162 self._no_commentclose: tuple[str | None, int] = (None, 0)
164 super().reset()
166 def close(self):
167 """Handle any buffered data."""
168 super().close()
169 if len(self.rawdata):
170 # Temp fix for https://bugs.python.org/issue41989
171 # TODO: remove this when the bug is fixed in all supported Python versions.
172 if self.convert_charrefs and not self.cdata_elem: # pragma: no cover
173 self.handle_data(htmlparser.unescape(self.rawdata))
174 else:
175 self.handle_data(self.rawdata)
176 # Handle any unclosed tags.
177 if len(self._cache):
178 self.cleandoc.append(self.md.htmlStash.store(''.join(self._cache)))
179 self._cache = []
181 @property
182 def line_offset(self) -> int:
183 """Returns char index in `self.rawdata` for the start of the current line. """
184 for ii in range(len(self.lineno_start_cache)-1, self.lineno-1):
185 last_line_start_pos = self.lineno_start_cache[ii]
186 lf_pos = self.rawdata.find('\n', last_line_start_pos)
187 if lf_pos == -1:
188 # No more newlines found. Use end of raw data as start of line beyond end.
189 lf_pos = len(self.rawdata)
190 self.lineno_start_cache.append(lf_pos+1)
192 return self.lineno_start_cache[self.lineno-1]
194 def at_line_start(self) -> bool:
195 """
196 Returns True if current position is at start of line.
198 Allows for up to three blank spaces at start of line.
199 """
200 if self.offset == 0:
201 return True
202 if self.offset > 3:
203 return False
204 # Confirm up to first 3 chars are whitespace
205 return self.rawdata[self.line_offset:self.line_offset + self.offset].strip() == ''
207 def get_endtag_text(self, tag: str) -> str:
208 """
209 Returns the text of the end tag.
211 If it fails to extract the actual text from the raw data, it builds a closing tag with `tag`.
212 """
213 # Attempt to extract actual tag from raw source text
214 start = self.line_offset + self.offset
215 m = htmlparser.endendtag.search(self.rawdata, start)
216 if m:
217 return self.rawdata[start:m.end()]
218 else: # pragma: no cover
219 # Failed to extract from raw data. Assume well formed and lowercase.
220 return '</{}>'.format(tag)
222 def handle_starttag(self, tag: str, attrs: Sequence[tuple[str, str]]):
223 # Handle tags that should always be empty and do not specify a closing tag
224 if tag in self.empty_tags:
225 self.handle_startendtag(tag, attrs)
226 return
228 if self.md.is_block_level(tag) and (self.intail or (self.at_line_start() and not self.inraw)):
229 # Started a new raw block. Prepare stack.
230 self.inraw = True
231 self.cleandoc.append('\n')
233 text = self.get_starttag_text()
234 if self.inraw:
235 self.stack.append(tag)
236 self._cache.append(text)
237 else:
238 self.cleandoc.append(text)
239 self.open_inline_tag(tag)
240 if tag in self.CDATA_CONTENT_ELEMENTS:
241 # This is presumably a standalone tag in a code span (see #1036).
242 self.clear_cdata_mode()
244 def handle_endtag(self, tag: str):
245 text = self.get_endtag_text(tag)
247 if self.inraw:
248 self._cache.append(text)
249 if tag in self.stack:
250 # Remove tag from stack
251 while self.stack:
252 if self.stack.pop() == tag:
253 break
254 if len(self.stack) == 0:
255 # End of raw block.
256 if blank_line_re.match(self.rawdata[self.line_offset + self.offset + len(text):]):
257 # Preserve blank line and end of raw block.
258 self._cache.append('\n')
259 else:
260 # More content exists after `endtag`.
261 self.intail = True
262 # Reset stack.
263 self.inraw = False
264 self.cleandoc.append(self.md.htmlStash.store(''.join(self._cache)))
265 # Insert blank line between this and next line.
266 self.cleandoc.append('\n\n')
267 self._cache = []
268 else:
269 self.cleandoc.append(text)
270 self.close_inline_tag(tag)
272 def open_inline_tag(self, tag: str):
273 """ Track an unclosed raw inline tag so a comment inside it is not treated as a block. """
274 if not self.md.is_block_level(tag) and tag not in void_tags:
275 self.inline_stack.append(tag)
277 def close_inline_tag(self, tag: str):
278 """ Stop tracking an inline tag and any unclosed inline tags opened after it. """
279 if tag in self.inline_stack:
280 while self.inline_stack:
281 if self.inline_stack.pop() == tag:
282 break
284 def inline_close_follows(self, text: str) -> bool:
285 """ Return `True` if an open inline tag is closed in the same paragraph after the comment `text`. """
286 if not self.inline_stack:
287 return False
288 rest = inner_blank_line_re.split(self.rawdata[self.line_offset + self.offset + len(text):], 1)[0]
289 return any(re.search(r'</\s*{}\s*>'.format(re.escape(tag)), rest, re.I) for tag in self.inline_stack)
291 def handle_data(self, data: str):
292 if self.intail and '\n' in data:
293 self.intail = False
294 if self.inraw:
295 self._cache.append(data)
296 else:
297 if inner_blank_line_re.search(data):
298 # A blank line ends the paragraph, so open inline tags no longer enclose what follows.
299 self.inline_stack = []
300 self.cleandoc.append(data)
302 def handle_empty_tag(self, data: str, is_block: bool):
303 """ Handle empty tags (`<data>`). """
304 if self.inraw or self.intail:
305 # Append this to the existing raw block
306 self._cache.append(data)
307 elif self.at_line_start() and is_block:
308 # Handle this as a standalone raw block
309 if blank_line_re.match(self.rawdata[self.line_offset + self.offset + len(data):]):
310 # Preserve blank line after tag in raw block.
311 data += '\n'
312 else:
313 # More content exists after tag.
314 self.intail = True
315 item = self.cleandoc[-1] if self.cleandoc else ''
316 # If we only have one newline before block element, add another
317 if not item.endswith('\n\n') and item.endswith('\n'):
318 self.cleandoc.append('\n')
319 self.cleandoc.append(self.md.htmlStash.store(data))
320 # Insert blank line between this and next line.
321 self.cleandoc.append('\n\n')
322 else:
323 self.cleandoc.append(data)
325 def handle_startendtag(self, tag: str, attrs):
326 self.handle_empty_tag(self.get_starttag_text(), is_block=self.md.is_block_level(tag))
328 def handle_charref(self, name: str):
329 self.handle_empty_tag('&#{};'.format(name), is_block=False)
331 def handle_entityref(self, name: str):
332 self.handle_empty_tag('&{};'.format(name), is_block=False)
334 def handle_comment(self, data: str):
335 # Check if the comment is unclosed, if so, we need to override position
336 text = '<!--{}-->'.format(data)
337 # A comment inside a raw inline element is part of that inline content, unless it contains a blank line.
338 is_block = bool(inner_blank_line_re.search(data)) or not self.inline_close_follows(text)
339 self.handle_empty_tag(text, is_block=is_block)
341 def handle_decl(self, data: str):
342 self.handle_empty_tag('<!{}>'.format(data), is_block=True)
344 def handle_pi(self, data: str):
345 self.handle_empty_tag('<?{}?>'.format(data), is_block=True)
347 def unknown_decl(self, data: str):
348 end = ']]>' if data.startswith('CDATA[') else ']>'
349 self.handle_empty_tag('<![{}{}'.format(data, end), is_block=True)
351 def parse_pi(self, i: int) -> int:
352 if self.at_line_start() or self.intail:
353 return super().parse_pi(i)
354 # This is not the beginning of a raw block so treat as plain data
355 # and avoid consuming any tags which may follow (see #1066).
356 self.handle_data('<?')
357 return i + 2
359 # Internal -- parse comment, return length or -1 if not terminated
360 # see https://html.spec.whatwg.org/multipage/parsing.html#comment-start-state
361 def parse_comment(self, i, report=True):
362 rawdata = self.rawdata
363 assert rawdata.startswith('<!--', i), 'unexpected call to parse_comment()'
364 # If no close was found after an earlier position in the same `rawdata`, there
365 # is none after this one either. Don't rescan the rest of the document again.
366 searched, pos = self._no_commentclose
367 if searched is rawdata and i + 4 >= pos:
368 match = None
369 else:
370 match = commentclose.search(rawdata, i+4)
371 if not match:
372 self._no_commentclose = (rawdata, i + 4)
373 self.handle_data('<')
374 return i + 1
375 if report:
376 j = match.start()
377 self.handle_comment(rawdata[i+4: j])
378 return match.end()
380 def parse_html_declaration(self, i: int) -> int:
381 if self.at_line_start() or self.intail:
382 if self.rawdata[i:i+3] == '<![' and not self.rawdata[i:i+9] == '<![CDATA[':
383 # We have encountered the bug in #1534 (Python bug `gh-77057`).
384 # Provide an override until we drop support for Python < 3.13.
385 result = self.parse_bogus_comment(i)
386 if result == -1:
387 self.handle_data(self.rawdata[i:i + 1])
388 return i + 1
389 return result
390 return super().parse_html_declaration(i)
391 # This is not the beginning of a raw block so treat as plain data
392 # and avoid consuming any tags which may follow (see #1066).
393 self.handle_data('<!')
394 return i + 2
396 def parse_bogus_comment(self, i: int, report: int = 0) -> int:
397 # Override the default behavior so that bogus comments get passed
398 # through unaltered by setting `report` to `0` (see #1425).
399 pos = super().parse_bogus_comment(i, report)
400 if pos == -1: # pragma: no cover
401 return -1
402 self.handle_empty_tag(self.rawdata[i:pos], is_block=False)
403 return pos
405 # The rest has been copied from base class in standard lib to address #1036.
406 # As `__startag_text` is private, all references to it must be in this subclass.
407 # The last few lines of `parse_starttag` are reversed so that `handle_starttag`
408 # can override `cdata_mode` in certain situations (in a code span).
409 __starttag_text: str | None = None
411 def get_starttag_text(self) -> str:
412 """Return full source of start tag: `<...>`."""
413 return self.__starttag_text
415 def parse_starttag(self, i: int) -> int: # pragma: no cover
416 # Treat `</>` as normal data as it is not a real tag.
417 if self.rawdata[i:i + 3] == '</>':
418 self.handle_data(self.rawdata[i:i + 3])
419 return i + 3
421 self.__starttag_text = None
422 endpos = self.check_for_whole_start_tag(i)
423 if endpos < 0:
424 self.handle_data(self.rawdata[i:i + 1])
425 return i + 1
426 rawdata = self.rawdata
427 self.__starttag_text = rawdata[i:endpos]
429 # Now parse the data between `i+1` and `j` into a tag and `attrs`
430 attrs = []
431 match = htmlparser.tagfind_tolerant.match(rawdata, i+1)
432 assert match, 'unexpected call to parse_starttag()'
433 k = match.end()
434 self.lasttag = tag = match.group(1).lower()
435 while k < endpos:
436 m = htmlparser.attrfind_tolerant.match(rawdata, k)
437 if not m:
438 break
439 attrname, rest, attrvalue = m.group(1, 2, 3)
440 if not rest:
441 attrvalue = None
442 elif attrvalue[:1] == '\'' == attrvalue[-1:] or \
443 attrvalue[:1] == '"' == attrvalue[-1:]: # noqa: E127
444 attrvalue = attrvalue[1:-1]
445 if attrvalue:
446 attrvalue = htmlparser.unescape(attrvalue)
447 attrs.append((attrname.lower(), attrvalue))
448 k = m.end()
450 end = rawdata[k:endpos].strip()
451 if end not in (">", "/>"):
452 lineno, offset = self.getpos()
453 if "\n" in self.__starttag_text:
454 lineno = lineno + self.__starttag_text.count("\n")
455 offset = len(self.__starttag_text) \
456 - self.__starttag_text.rfind("\n") # noqa: E127
457 else:
458 offset = offset + len(self.__starttag_text)
459 self.handle_data(rawdata[i:endpos])
460 return endpos
461 if end.endswith('/>'):
462 # XHTML-style empty tag: `<span attr="value" />`
463 self.handle_startendtag(tag, attrs)
464 else:
465 # *** set `cdata_mode` first so we can override it in `handle_starttag` (see #1036) ***
466 if tag in self.CDATA_CONTENT_ELEMENTS:
467 self.set_cdata_mode(tag)
468 self.handle_starttag(tag, attrs)
469 return endpos