Spaces:
Paused
Paused
| """html2text: Turn HTML into equivalent Markdown-structured text.""" | |
| import html.entities | |
| import html.parser | |
| import re | |
| import string | |
| import urllib.parse as urlparse | |
| from textwrap import wrap | |
| from typing import Dict, List, Optional, Tuple, Union | |
| from . import config | |
| from ._typing import OutCallback | |
| from .elements import AnchorElement, ListElement | |
| from .utils import ( | |
| dumb_css_parser, | |
| element_style, | |
| escape_md, | |
| escape_md_section, | |
| google_fixed_width_font, | |
| google_has_height, | |
| google_list_style, | |
| google_text_emphasis, | |
| hn, | |
| list_numbering_start, | |
| pad_tables_in_text, | |
| skipwrap, | |
| unifiable_n, | |
| ) | |
| __version__ = (2024, 2, 26) | |
| # TODO: | |
| # Support decoded entities with UNIFIABLE. | |
| class HTML2Text(html.parser.HTMLParser): | |
| def __init__( | |
| self, | |
| out: Optional[OutCallback] = None, | |
| baseurl: str = "", | |
| bodywidth: int = config.BODY_WIDTH, | |
| ) -> None: | |
| """ | |
| Input parameters: | |
| out: possible custom replacement for self.outtextf (which | |
| appends lines of text). | |
| baseurl: base URL of the document we process | |
| """ | |
| super().__init__(convert_charrefs=False) | |
| # Config options | |
| self.split_next_td = False | |
| self.td_count = 0 | |
| self.table_start = False | |
| self.unicode_snob = config.UNICODE_SNOB # covered in cli | |
| self.escape_snob = config.ESCAPE_SNOB # covered in cli | |
| self.escape_backslash = config.ESCAPE_BACKSLASH # covered in cli | |
| self.escape_dot = config.ESCAPE_DOT # covered in cli | |
| self.escape_plus = config.ESCAPE_PLUS # covered in cli | |
| self.escape_dash = config.ESCAPE_DASH # covered in cli | |
| self.links_each_paragraph = config.LINKS_EACH_PARAGRAPH | |
| self.body_width = bodywidth # covered in cli | |
| self.skip_internal_links = config.SKIP_INTERNAL_LINKS # covered in cli | |
| self.inline_links = config.INLINE_LINKS # covered in cli | |
| self.protect_links = config.PROTECT_LINKS # covered in cli | |
| self.google_list_indent = config.GOOGLE_LIST_INDENT # covered in cli | |
| self.ignore_links = config.IGNORE_ANCHORS # covered in cli | |
| self.ignore_mailto_links = config.IGNORE_MAILTO_LINKS # covered in cli | |
| self.ignore_images = config.IGNORE_IMAGES # covered in cli | |
| self.images_as_html = config.IMAGES_AS_HTML # covered in cli | |
| self.images_to_alt = config.IMAGES_TO_ALT # covered in cli | |
| self.images_with_size = config.IMAGES_WITH_SIZE # covered in cli | |
| self.ignore_emphasis = config.IGNORE_EMPHASIS # covered in cli | |
| self.bypass_tables = config.BYPASS_TABLES # covered in cli | |
| self.ignore_tables = config.IGNORE_TABLES # covered in cli | |
| self.google_doc = False # covered in cli | |
| self.ul_item_mark = "*" # covered in cli | |
| self.emphasis_mark = "_" # covered in cli | |
| self.strong_mark = "**" | |
| self.single_line_break = config.SINGLE_LINE_BREAK # covered in cli | |
| self.use_automatic_links = config.USE_AUTOMATIC_LINKS # covered in cli | |
| self.hide_strikethrough = False # covered in cli | |
| self.mark_code = config.MARK_CODE | |
| self.wrap_list_items = config.WRAP_LIST_ITEMS # covered in cli | |
| self.wrap_links = config.WRAP_LINKS # covered in cli | |
| self.wrap_tables = config.WRAP_TABLES | |
| self.pad_tables = config.PAD_TABLES # covered in cli | |
| self.default_image_alt = config.DEFAULT_IMAGE_ALT # covered in cli | |
| self.tag_callback = None | |
| self.open_quote = config.OPEN_QUOTE # covered in cli | |
| self.close_quote = config.CLOSE_QUOTE # covered in cli | |
| self.include_sup_sub = config.INCLUDE_SUP_SUB # covered in cli | |
| if out is None: | |
| self.out = self.outtextf | |
| else: | |
| self.out = out | |
| # empty list to store output characters before they are "joined" | |
| self.outtextlist: List[str] = [] | |
| self.quiet = 0 | |
| self.p_p = 0 # number of newline character to print before next output | |
| self.outcount = 0 | |
| self.start = True | |
| self.space = False | |
| self.a: List[AnchorElement] = [] | |
| self.astack: List[Optional[Dict[str, Optional[str]]]] = [] | |
| self.maybe_automatic_link: Optional[str] = None | |
| self.empty_link = False | |
| self.absolute_url_matcher = re.compile(r"^[a-zA-Z+]+://") | |
| self.acount = 0 | |
| self.list: List[ListElement] = [] | |
| self.blockquote = 0 | |
| self.pre = False | |
| self.startpre = False | |
| self.code = False | |
| self.quote = False | |
| self.br_toggle = "" | |
| self.lastWasNL = False | |
| self.lastWasList = False | |
| self.style = 0 | |
| self.style_def: Dict[str, Dict[str, str]] = {} | |
| self.tag_stack: List[Tuple[str, Dict[str, Optional[str]], Dict[str, str]]] = [] | |
| self.emphasis = 0 | |
| self.drop_white_space = 0 | |
| self.inheader = False | |
| # Current abbreviation definition | |
| self.abbr_title: Optional[str] = None | |
| # Last inner HTML (for abbr being defined) | |
| self.abbr_data: Optional[str] = None | |
| # Stack of abbreviations to write later | |
| self.abbr_list: Dict[str, str] = {} | |
| self.baseurl = baseurl | |
| self.stressed = False | |
| self.preceding_stressed = False | |
| self.preceding_data = "" | |
| self.current_tag = "" | |
| config.UNIFIABLE["nbsp"] = " _place_holder;" | |
| def update_params(self, **kwargs): | |
| for key, value in kwargs.items(): | |
| setattr(self, key, value) | |
| def feed(self, data: str) -> None: | |
| data = data.replace("</' + 'script>", "</ignore>") | |
| super().feed(data) | |
| def handle(self, data: str) -> str: | |
| self.start = True | |
| self.feed(data) | |
| self.feed("") | |
| markdown = self.optwrap(self.finish()) | |
| if self.pad_tables: | |
| return pad_tables_in_text(markdown) | |
| else: | |
| return markdown | |
| def outtextf(self, s: str) -> None: | |
| self.outtextlist.append(s) | |
| if s: | |
| self.lastWasNL = s[-1] == "\n" | |
| def finish(self) -> str: | |
| self.close() | |
| self.pbr() | |
| self.o("", force="end") | |
| outtext = "".join(self.outtextlist) | |
| if self.unicode_snob: | |
| nbsp = html.entities.html5["nbsp;"] | |
| else: | |
| nbsp = " " | |
| outtext = outtext.replace(" _place_holder;", nbsp) | |
| # Clear self.outtextlist to avoid memory leak of its content to | |
| # the next handling. | |
| self.outtextlist = [] | |
| return outtext | |
| def handle_charref(self, c: str) -> None: | |
| self.handle_data(self.charref(c), True) | |
| def handle_entityref(self, c: str) -> None: | |
| ref = self.entityref(c) | |
| # ref may be an empty string (e.g. for ‎/‏ markers that should | |
| # not contribute to the final output). | |
| # self.handle_data cannot handle a zero-length string right after a | |
| # stressed tag or mid-text within a stressed tag (text get split and | |
| # self.stressed/self.preceding_stressed gets switched after the first | |
| # part of that text). | |
| if ref: | |
| self.handle_data(ref, True) | |
| def handle_starttag(self, tag: str, attrs: List[Tuple[str, Optional[str]]]) -> None: | |
| self.handle_tag(tag, dict(attrs), start=True) | |
| def handle_endtag(self, tag: str) -> None: | |
| self.handle_tag(tag, {}, start=False) | |
| def previousIndex(self, attrs: Dict[str, Optional[str]]) -> Optional[int]: | |
| """ | |
| :type attrs: dict | |
| :returns: The index of certain set of attributes (of a link) in the | |
| self.a list. If the set of attributes is not found, returns None | |
| :rtype: int | |
| """ | |
| if "href" not in attrs: | |
| return None | |
| match = False | |
| for i, a in enumerate(self.a): | |
| if "href" in a.attrs and a.attrs["href"] == attrs["href"]: | |
| if "title" in a.attrs or "title" in attrs: | |
| if ( | |
| "title" in a.attrs | |
| and "title" in attrs | |
| and a.attrs["title"] == attrs["title"] | |
| ): | |
| match = True | |
| else: | |
| match = True | |
| if match: | |
| return i | |
| return None | |
| def handle_emphasis( | |
| self, start: bool, tag_style: Dict[str, str], parent_style: Dict[str, str] | |
| ) -> None: | |
| """ | |
| Handles various text emphases | |
| """ | |
| tag_emphasis = google_text_emphasis(tag_style) | |
| parent_emphasis = google_text_emphasis(parent_style) | |
| # handle Google's text emphasis | |
| strikethrough = "line-through" in tag_emphasis and self.hide_strikethrough | |
| # google and others may mark a font's weight as `bold` or `700` | |
| bold = False | |
| for bold_marker in config.BOLD_TEXT_STYLE_VALUES: | |
| bold = bold_marker in tag_emphasis and bold_marker not in parent_emphasis | |
| if bold: | |
| break | |
| italic = "italic" in tag_emphasis and "italic" not in parent_emphasis | |
| fixed = ( | |
| google_fixed_width_font(tag_style) | |
| and not google_fixed_width_font(parent_style) | |
| and not self.pre | |
| ) | |
| if start: | |
| # crossed-out text must be handled before other attributes | |
| # in order not to output qualifiers unnecessarily | |
| if bold or italic or fixed: | |
| self.emphasis += 1 | |
| if strikethrough: | |
| self.quiet += 1 | |
| if italic: | |
| self.o(self.emphasis_mark) | |
| self.drop_white_space += 1 | |
| if bold: | |
| self.o(self.strong_mark) | |
| self.drop_white_space += 1 | |
| if fixed: | |
| self.o("`") | |
| self.drop_white_space += 1 | |
| self.code = True | |
| else: | |
| if bold or italic or fixed: | |
| # there must not be whitespace before closing emphasis mark | |
| self.emphasis -= 1 | |
| self.space = False | |
| if fixed: | |
| if self.drop_white_space: | |
| # empty emphasis, drop it | |
| self.drop_white_space -= 1 | |
| else: | |
| self.o("`") | |
| self.code = False | |
| if bold: | |
| if self.drop_white_space: | |
| # empty emphasis, drop it | |
| self.drop_white_space -= 1 | |
| else: | |
| self.o(self.strong_mark) | |
| if italic: | |
| if self.drop_white_space: | |
| # empty emphasis, drop it | |
| self.drop_white_space -= 1 | |
| else: | |
| self.o(self.emphasis_mark) | |
| # space is only allowed after *all* emphasis marks | |
| if (bold or italic) and not self.emphasis: | |
| self.o(" ") | |
| if strikethrough: | |
| self.quiet -= 1 | |
| def handle_tag( | |
| self, tag: str, attrs: Dict[str, Optional[str]], start: bool | |
| ) -> None: | |
| self.current_tag = tag | |
| if self.tag_callback is not None: | |
| if self.tag_callback(self, tag, attrs, start) is True: | |
| return | |
| # first thing inside the anchor tag is another tag | |
| # that produces some output | |
| if ( | |
| start | |
| and self.maybe_automatic_link is not None | |
| and tag not in ["p", "div", "style", "dl", "dt"] | |
| and (tag != "img" or self.ignore_images) | |
| ): | |
| self.o("[") | |
| self.maybe_automatic_link = None | |
| self.empty_link = False | |
| if self.google_doc: | |
| # the attrs parameter is empty for a closing tag. in addition, we | |
| # need the attributes of the parent nodes in order to get a | |
| # complete style description for the current element. we assume | |
| # that google docs export well formed html. | |
| parent_style: Dict[str, str] = {} | |
| if start: | |
| if self.tag_stack: | |
| parent_style = self.tag_stack[-1][2] | |
| tag_style = element_style(attrs, self.style_def, parent_style) | |
| self.tag_stack.append((tag, attrs, tag_style)) | |
| else: | |
| dummy, attrs, tag_style = ( | |
| self.tag_stack.pop() if self.tag_stack else (None, {}, {}) | |
| ) | |
| if self.tag_stack: | |
| parent_style = self.tag_stack[-1][2] | |
| if hn(tag): | |
| # check if nh is inside of an 'a' tag (incorrect but found in the wild) | |
| if self.astack: | |
| if start: | |
| self.inheader = True | |
| # are inside link name, so only add '#' if it can appear before '[' | |
| if self.outtextlist and self.outtextlist[-1] == "[": | |
| self.outtextlist.pop() | |
| self.space = False | |
| self.o(hn(tag) * "#" + " ") | |
| self.o("[") | |
| else: | |
| self.p_p = 0 # don't break up link name | |
| self.inheader = False | |
| return # prevent redundant emphasis marks on headers | |
| else: | |
| self.p() | |
| if start: | |
| self.inheader = True | |
| self.o(hn(tag) * "#" + " ") | |
| else: | |
| self.inheader = False | |
| return # prevent redundant emphasis marks on headers | |
| if tag in ["p", "div"]: | |
| if self.google_doc: | |
| if start and google_has_height(tag_style): | |
| self.p() | |
| else: | |
| self.soft_br() | |
| elif self.astack: | |
| pass | |
| elif self.split_next_td: | |
| pass | |
| else: | |
| self.p() | |
| if tag == "br" and start: | |
| if self.blockquote > 0: | |
| self.o(" \n> ") | |
| else: | |
| self.o(" \n") | |
| if tag == "hr" and start: | |
| self.p() | |
| self.o("* * *") | |
| self.p() | |
| if tag in ["head", "style", "script"]: | |
| if start: | |
| self.quiet += 1 | |
| else: | |
| self.quiet -= 1 | |
| if tag == "style": | |
| if start: | |
| self.style += 1 | |
| else: | |
| self.style -= 1 | |
| if tag in ["body"]: | |
| self.quiet = 0 # sites like 9rules.com never close <head> | |
| if tag == "blockquote": | |
| if start: | |
| self.p() | |
| self.o("> ", force=True) | |
| self.start = True | |
| self.blockquote += 1 | |
| else: | |
| self.blockquote -= 1 | |
| self.p() | |
| if tag in ["em", "i", "u"] and not self.ignore_emphasis: | |
| # Separate with a space if we immediately follow an alphanumeric | |
| # character, since otherwise Markdown won't render the emphasis | |
| # marks, and we'll be left with eg 'foo_bar_' visible. | |
| # (Don't add a space otherwise, though, since there isn't one in the | |
| # original HTML.) | |
| if ( | |
| start | |
| and self.preceding_data | |
| and self.preceding_data[-1] not in string.whitespace | |
| and self.preceding_data[-1] not in string.punctuation | |
| ): | |
| emphasis = " " + self.emphasis_mark | |
| self.preceding_data += " " | |
| else: | |
| emphasis = self.emphasis_mark | |
| self.o(emphasis) | |
| if start: | |
| self.stressed = True | |
| if tag in ["strong", "b"] and not self.ignore_emphasis: | |
| # Separate with space if we immediately follow an * character, since | |
| # without it, Markdown won't render the resulting *** correctly. | |
| # (Don't add a space otherwise, though, since there isn't one in the | |
| # original HTML.) | |
| if ( | |
| start | |
| and self.preceding_data | |
| # When `self.strong_mark` is set to empty, the next condition | |
| # will cause IndexError since it's trying to match the data | |
| # with the first character of the `self.strong_mark`. | |
| and len(self.strong_mark) > 0 | |
| and self.preceding_data[-1] == self.strong_mark[0] | |
| ): | |
| strong = " " + self.strong_mark | |
| self.preceding_data += " " | |
| else: | |
| strong = self.strong_mark | |
| self.o(strong) | |
| if start: | |
| self.stressed = True | |
| if tag in ["del", "strike", "s"]: | |
| if start and self.preceding_data and self.preceding_data[-1] == "~": | |
| strike = " ~~" | |
| self.preceding_data += " " | |
| else: | |
| strike = "~~" | |
| self.o(strike) | |
| if start: | |
| self.stressed = True | |
| if self.google_doc: | |
| if not self.inheader: | |
| # handle some font attributes, but leave headers clean | |
| self.handle_emphasis(start, tag_style, parent_style) | |
| if tag in ["kbd", "code", "tt"] and not self.pre: | |
| self.o("`") # TODO: `` `this` `` | |
| self.code = not self.code | |
| if tag == "abbr": | |
| if start: | |
| self.abbr_title = None | |
| self.abbr_data = "" | |
| if "title" in attrs: | |
| self.abbr_title = attrs["title"] | |
| else: | |
| if self.abbr_title is not None: | |
| assert self.abbr_data is not None | |
| self.abbr_list[self.abbr_data] = self.abbr_title | |
| self.abbr_title = None | |
| self.abbr_data = None | |
| if tag == "q": | |
| if not self.quote: | |
| self.o(self.open_quote) | |
| else: | |
| self.o(self.close_quote) | |
| self.quote = not self.quote | |
| def link_url(self: HTML2Text, link: str, title: str = "") -> None: | |
| url = urlparse.urljoin(self.baseurl, link) | |
| title = ' "{}"'.format(title) if title.strip() else "" | |
| self.o("]({url}{title})".format(url=escape_md(url), title=title)) | |
| if tag == "a" and not self.ignore_links: | |
| if start: | |
| if ( | |
| "href" in attrs | |
| and attrs["href"] is not None | |
| and not (self.skip_internal_links and attrs["href"].startswith("#")) | |
| and not ( | |
| self.ignore_mailto_links and attrs["href"].startswith("mailto:") | |
| ) | |
| ): | |
| self.astack.append(attrs) | |
| self.maybe_automatic_link = attrs["href"] | |
| self.empty_link = True | |
| if self.protect_links: | |
| attrs["href"] = "<" + attrs["href"] + ">" | |
| else: | |
| self.astack.append(None) | |
| else: | |
| if self.astack: | |
| a = self.astack.pop() | |
| if self.maybe_automatic_link and not self.empty_link: | |
| self.maybe_automatic_link = None | |
| elif a: | |
| assert a["href"] is not None | |
| if self.empty_link: | |
| self.o("[") | |
| self.empty_link = False | |
| self.maybe_automatic_link = None | |
| if self.inline_links: | |
| self.p_p = 0 | |
| title = a.get("title") or "" | |
| title = escape_md(title) | |
| link_url(self, a["href"], title) | |
| else: | |
| i = self.previousIndex(a) | |
| if i is not None: | |
| a_props = self.a[i] | |
| else: | |
| self.acount += 1 | |
| a_props = AnchorElement(a, self.acount, self.outcount) | |
| self.a.append(a_props) | |
| self.o("][" + str(a_props.count) + "]") | |
| if tag == "img" and start and not self.ignore_images: | |
| if "src" in attrs and attrs["src"] is not None: | |
| if not self.images_to_alt: | |
| attrs["href"] = attrs["src"] | |
| alt = attrs.get("alt") or self.default_image_alt | |
| # If we have images_with_size, write raw html including width, | |
| # height, and alt attributes | |
| if self.images_as_html or ( | |
| self.images_with_size and ("width" in attrs or "height" in attrs) | |
| ): | |
| self.o("<img src='" + attrs["src"] + "' ") | |
| if "width" in attrs and attrs["width"] is not None: | |
| self.o("width='" + attrs["width"] + "' ") | |
| if "height" in attrs and attrs["height"] is not None: | |
| self.o("height='" + attrs["height"] + "' ") | |
| if alt: | |
| self.o("alt='" + alt + "' ") | |
| self.o("/>") | |
| return | |
| # If we have a link to create, output the start | |
| if self.maybe_automatic_link is not None: | |
| href = self.maybe_automatic_link | |
| if ( | |
| self.images_to_alt | |
| and escape_md(alt) == href | |
| and self.absolute_url_matcher.match(href) | |
| ): | |
| self.o("<" + escape_md(alt) + ">") | |
| self.empty_link = False | |
| return | |
| else: | |
| self.o("[") | |
| self.maybe_automatic_link = None | |
| self.empty_link = False | |
| # If we have images_to_alt, we discard the image itself, | |
| # considering only the alt text. | |
| if self.images_to_alt: | |
| self.o(escape_md(alt)) | |
| else: | |
| self.o("![" + escape_md(alt) + "]") | |
| if self.inline_links: | |
| href = attrs.get("href") or "" | |
| self.o( | |
| "(" + escape_md(urlparse.urljoin(self.baseurl, href)) + ")" | |
| ) | |
| else: | |
| i = self.previousIndex(attrs) | |
| if i is not None: | |
| a_props = self.a[i] | |
| else: | |
| self.acount += 1 | |
| a_props = AnchorElement(attrs, self.acount, self.outcount) | |
| self.a.append(a_props) | |
| self.o("[" + str(a_props.count) + "]") | |
| if tag == "dl" and start: | |
| self.p() | |
| if tag == "dt" and not start: | |
| self.pbr() | |
| if tag == "dd" and start: | |
| self.o(" ") | |
| if tag == "dd" and not start: | |
| self.pbr() | |
| if tag in ["ol", "ul"]: | |
| # Google Docs create sub lists as top level lists | |
| if not self.list and not self.lastWasList: | |
| self.p() | |
| if start: | |
| if self.google_doc: | |
| list_style = google_list_style(tag_style) | |
| else: | |
| list_style = tag | |
| numbering_start = list_numbering_start(attrs) | |
| self.list.append(ListElement(list_style, numbering_start)) | |
| else: | |
| if self.list: | |
| self.list.pop() | |
| if not self.google_doc and not self.list: | |
| self.o("\n") | |
| self.lastWasList = True | |
| else: | |
| self.lastWasList = False | |
| if tag == "li": | |
| self.pbr() | |
| if start: | |
| if self.list: | |
| li = self.list[-1] | |
| else: | |
| li = ListElement("ul", 0) | |
| if self.google_doc: | |
| self.o(" " * self.google_nest_count(tag_style)) | |
| else: | |
| # Indent two spaces per list, except use three spaces for an | |
| # unordered list inside an ordered list. | |
| # https://spec.commonmark.org/0.28/#motivation | |
| # TODO: line up <ol><li>s > 9 correctly. | |
| parent_list = None | |
| for list in self.list: | |
| self.o( | |
| " " if parent_list == "ol" and list.name == "ul" else " " | |
| ) | |
| parent_list = list.name | |
| if li.name == "ul": | |
| self.o(self.ul_item_mark + " ") | |
| elif li.name == "ol": | |
| li.num += 1 | |
| self.o(str(li.num) + ". ") | |
| self.start = True | |
| if tag in ["table", "tr", "td", "th"]: | |
| if self.ignore_tables: | |
| if tag == "tr": | |
| if start: | |
| pass | |
| else: | |
| self.soft_br() | |
| else: | |
| pass | |
| elif self.bypass_tables: | |
| if start: | |
| self.soft_br() | |
| if tag in ["td", "th"]: | |
| if start: | |
| self.o("<{}>\n\n".format(tag)) | |
| else: | |
| self.o("\n</{}>".format(tag)) | |
| else: | |
| if start: | |
| self.o("<{}>".format(tag)) | |
| else: | |
| self.o("</{}>".format(tag)) | |
| else: | |
| if tag == "table": | |
| if start: | |
| self.table_start = True | |
| if self.pad_tables: | |
| self.o("<" + config.TABLE_MARKER_FOR_PAD + ">") | |
| self.o(" \n") | |
| else: | |
| if self.pad_tables: | |
| # add break in case the table is empty or its 1 row table | |
| self.soft_br() | |
| self.o("</" + config.TABLE_MARKER_FOR_PAD + ">") | |
| self.o(" \n") | |
| if tag in ["td", "th"] and start: | |
| if self.split_next_td: | |
| self.o("| ") | |
| self.split_next_td = True | |
| if tag == "tr" and start: | |
| self.td_count = 0 | |
| if tag == "tr" and not start: | |
| self.split_next_td = False | |
| self.soft_br() | |
| if tag == "tr" and not start and self.table_start: | |
| # Underline table header | |
| self.o("|".join(["---"] * self.td_count)) | |
| self.soft_br() | |
| self.table_start = False | |
| if tag in ["td", "th"] and start: | |
| self.td_count += 1 | |
| if tag == "pre": | |
| if start: | |
| self.startpre = True | |
| self.pre = True | |
| else: | |
| self.pre = False | |
| if self.mark_code: | |
| self.out("\n[/code]") | |
| self.p() | |
| if tag in ["sup", "sub"] and self.include_sup_sub: | |
| if start: | |
| self.o("<{}>".format(tag)) | |
| else: | |
| self.o("</{}>".format(tag)) | |
| # TODO: Add docstring for these one letter functions | |
| def pbr(self) -> None: | |
| "Pretty print has a line break" | |
| if self.p_p == 0: | |
| self.p_p = 1 | |
| def p(self) -> None: | |
| "Set pretty print to 1 or 2 lines" | |
| self.p_p = 1 if self.single_line_break else 2 | |
| def soft_br(self) -> None: | |
| "Soft breaks" | |
| self.pbr() | |
| self.br_toggle = " " | |
| def o( | |
| self, data: str, puredata: bool = False, force: Union[bool, str] = False | |
| ) -> None: | |
| """ | |
| Deal with indentation and whitespace | |
| """ | |
| if self.abbr_data is not None: | |
| self.abbr_data += data | |
| if not self.quiet: | |
| if self.google_doc: | |
| # prevent white space immediately after 'begin emphasis' | |
| # marks ('**' and '_') | |
| lstripped_data = data.lstrip() | |
| if self.drop_white_space and not (self.pre or self.code): | |
| data = lstripped_data | |
| if lstripped_data != "": | |
| self.drop_white_space = 0 | |
| if puredata and not self.pre: | |
| # This is a very dangerous call ... it could mess up | |
| # all handling of when not handled properly | |
| # (see entityref) | |
| data = re.sub(r"\s+", r" ", data) | |
| if data and data[0] == " ": | |
| self.space = True | |
| data = data[1:] | |
| if not data and not force: | |
| return | |
| if self.startpre: | |
| # self.out(" :") #TODO: not output when already one there | |
| if not data.startswith("\n") and not data.startswith("\r\n"): | |
| # <pre>stuff... | |
| data = "\n" + data | |
| if self.mark_code: | |
| self.out("\n[code]") | |
| self.p_p = 0 | |
| bq = ">" * self.blockquote | |
| if not (force and data and data[0] == ">") and self.blockquote: | |
| bq += " " | |
| if self.pre: | |
| if not self.list: | |
| bq += " " | |
| # else: list content is already partially indented | |
| bq += " " * len(self.list) | |
| data = data.replace("\n", "\n" + bq) | |
| if self.startpre: | |
| self.startpre = False | |
| if self.list: | |
| # use existing initial indentation | |
| data = data.lstrip("\n") | |
| if self.start: | |
| self.space = False | |
| self.p_p = 0 | |
| self.start = False | |
| if force == "end": | |
| # It's the end. | |
| self.p_p = 0 | |
| self.out("\n") | |
| self.space = False | |
| if self.p_p: | |
| self.out((self.br_toggle + "\n" + bq) * self.p_p) | |
| self.space = False | |
| self.br_toggle = "" | |
| if self.space: | |
| if not self.lastWasNL: | |
| self.out(" ") | |
| self.space = False | |
| if self.a and ( | |
| (self.p_p == 2 and self.links_each_paragraph) or force == "end" | |
| ): | |
| if force == "end": | |
| self.out("\n") | |
| newa = [] | |
| for link in self.a: | |
| if self.outcount > link.outcount: | |
| self.out( | |
| " [" | |
| + str(link.count) | |
| + "]: " | |
| + urlparse.urljoin(self.baseurl, link.attrs["href"]) | |
| ) | |
| if "title" in link.attrs and link.attrs["title"] is not None: | |
| self.out(" (" + link.attrs["title"] + ")") | |
| self.out("\n") | |
| else: | |
| newa.append(link) | |
| # Don't need an extra line when nothing was done. | |
| if self.a != newa: | |
| self.out("\n") | |
| self.a = newa | |
| if self.abbr_list and force == "end": | |
| for abbr, definition in self.abbr_list.items(): | |
| self.out(" *[" + abbr + "]: " + definition + "\n") | |
| self.p_p = 0 | |
| self.out(data) | |
| self.outcount += 1 | |
| def handle_data(self, data: str, entity_char: bool = False) -> None: | |
| if not data: | |
| # Data may be empty for some HTML entities. For example, | |
| # LEFT-TO-RIGHT MARK. | |
| return | |
| if self.stressed: | |
| data = data.strip() | |
| self.stressed = False | |
| self.preceding_stressed = True | |
| elif self.preceding_stressed: | |
| if ( | |
| re.match(r"[^][(){}\s.!?]", data[0]) | |
| and not hn(self.current_tag) | |
| and self.current_tag not in ["a", "code", "pre"] | |
| ): | |
| # should match a letter or common punctuation | |
| data = " " + data | |
| self.preceding_stressed = False | |
| if self.style: | |
| self.style_def.update(dumb_css_parser(data)) | |
| if self.maybe_automatic_link is not None: | |
| href = self.maybe_automatic_link | |
| if ( | |
| href == data | |
| and self.absolute_url_matcher.match(href) | |
| and self.use_automatic_links | |
| ): | |
| self.o("<" + data + ">") | |
| self.empty_link = False | |
| return | |
| else: | |
| self.o("[") | |
| self.maybe_automatic_link = None | |
| self.empty_link = False | |
| if not self.code and not self.pre and not entity_char: | |
| data = escape_md_section(data, snob=self.escape_snob, escape_dot=self.escape_dot, escape_plus=self.escape_plus, escape_dash=self.escape_dash) | |
| self.preceding_data = data | |
| self.o(data, puredata=True) | |
| def charref(self, name: str) -> str: | |
| if name[0] in ["x", "X"]: | |
| c = int(name[1:], 16) | |
| else: | |
| c = int(name) | |
| if not self.unicode_snob and c in unifiable_n: | |
| return unifiable_n[c] | |
| else: | |
| try: | |
| return chr(c) | |
| except ValueError: # invalid unicode | |
| return "" | |
| def entityref(self, c: str) -> str: | |
| if not self.unicode_snob and c in config.UNIFIABLE: | |
| return config.UNIFIABLE[c] | |
| try: | |
| ch = html.entities.html5[c + ";"] | |
| except KeyError: | |
| return "&" + c + ";" | |
| return config.UNIFIABLE[c] if c == "nbsp" else ch | |
| def google_nest_count(self, style: Dict[str, str]) -> int: | |
| """ | |
| Calculate the nesting count of google doc lists | |
| :type style: dict | |
| :rtype: int | |
| """ | |
| nest_count = 0 | |
| if "margin-left" in style: | |
| nest_count = int(style["margin-left"][:-2]) // self.google_list_indent | |
| return nest_count | |
| def optwrap(self, text: str) -> str: | |
| """ | |
| Wrap all paragraphs in the provided text. | |
| :type text: str | |
| :rtype: str | |
| """ | |
| if not self.body_width: | |
| return text | |
| result = "" | |
| newlines = 0 | |
| # I cannot think of a better solution for now. | |
| # To avoid the non-wrap behaviour for entire paras | |
| # because of the presence of a link in it | |
| if not self.wrap_links: | |
| self.inline_links = False | |
| for para in text.split("\n"): | |
| if len(para) > 0: | |
| if not skipwrap( | |
| para, self.wrap_links, self.wrap_list_items, self.wrap_tables | |
| ): | |
| indent = "" | |
| if para.startswith(" " + self.ul_item_mark): | |
| # list item continuation: add a double indent to the | |
| # new lines | |
| indent = " " | |
| elif para.startswith("> "): | |
| # blockquote continuation: add the greater than symbol | |
| # to the new lines | |
| indent = "> " | |
| wrapped = wrap( | |
| para, | |
| self.body_width, | |
| break_long_words=False, | |
| subsequent_indent=indent, | |
| ) | |
| result += "\n".join(wrapped) | |
| if para.endswith(" "): | |
| result += " \n" | |
| newlines = 1 | |
| elif indent: | |
| result += "\n" | |
| newlines = 1 | |
| else: | |
| result += "\n\n" | |
| newlines = 2 | |
| else: | |
| # Warning for the tempted!!! | |
| # Be aware that obvious replacement of this with | |
| # line.isspace() | |
| # DOES NOT work! Explanations are welcome. | |
| if not config.RE_SPACE.match(para): | |
| result += para + "\n" | |
| newlines = 1 | |
| else: | |
| if newlines < 2: | |
| result += "\n" | |
| newlines += 1 | |
| return result | |
| def html2text(html: str, baseurl: str = "", bodywidth: Optional[int] = None) -> str: | |
| if bodywidth is None: | |
| bodywidth = config.BODY_WIDTH | |
| h = HTML2Text(baseurl=baseurl, bodywidth=bodywidth) | |
| return h.handle(html) | |
| class CustomHTML2Text(HTML2Text): | |
| def __init__(self, *args, handle_code_in_pre=False, **kwargs): | |
| super().__init__(*args, **kwargs) | |
| self.inside_pre = False | |
| self.inside_code = False | |
| self.preserve_tags = set() # Set of tags to preserve | |
| self.current_preserved_tag = None | |
| self.preserved_content = [] | |
| self.preserve_depth = 0 | |
| self.handle_code_in_pre = handle_code_in_pre | |
| # Configuration options | |
| self.skip_internal_links = False | |
| self.single_line_break = False | |
| self.mark_code = False | |
| self.include_sup_sub = False | |
| self.body_width = 0 | |
| self.ignore_mailto_links = True | |
| self.ignore_links = False | |
| self.escape_backslash = False | |
| self.escape_dot = False | |
| self.escape_plus = False | |
| self.escape_dash = False | |
| self.escape_snob = False | |
| def update_params(self, **kwargs): | |
| """Update parameters and set preserved tags.""" | |
| for key, value in kwargs.items(): | |
| if key == 'preserve_tags': | |
| self.preserve_tags = set(value) | |
| elif key == 'handle_code_in_pre': | |
| self.handle_code_in_pre = value | |
| else: | |
| setattr(self, key, value) | |
| def handle_tag(self, tag, attrs, start): | |
| # Handle preserved tags | |
| if tag in self.preserve_tags: | |
| if start: | |
| if self.preserve_depth == 0: | |
| self.current_preserved_tag = tag | |
| self.preserved_content = [] | |
| # Format opening tag with attributes | |
| attr_str = ''.join(f' {k}="{v}"' for k, v in attrs.items() if v is not None) | |
| self.preserved_content.append(f'<{tag}{attr_str}>') | |
| self.preserve_depth += 1 | |
| return | |
| else: | |
| self.preserve_depth -= 1 | |
| if self.preserve_depth == 0: | |
| self.preserved_content.append(f'</{tag}>') | |
| # Output the preserved HTML block with proper spacing | |
| preserved_html = ''.join(self.preserved_content) | |
| self.o('\n' + preserved_html + '\n') | |
| self.current_preserved_tag = None | |
| return | |
| # If we're inside a preserved tag, collect all content | |
| if self.preserve_depth > 0: | |
| if start: | |
| # Format nested tags with attributes | |
| attr_str = ''.join(f' {k}="{v}"' for k, v in attrs.items() if v is not None) | |
| self.preserved_content.append(f'<{tag}{attr_str}>') | |
| else: | |
| self.preserved_content.append(f'</{tag}>') | |
| return | |
| # Handle pre tags | |
| if tag == 'pre': | |
| if start: | |
| self.o('```\n') # Markdown code block start | |
| self.inside_pre = True | |
| else: | |
| self.o('\n```\n') # Markdown code block end | |
| self.inside_pre = False | |
| elif tag == 'code': | |
| if self.inside_pre and not self.handle_code_in_pre: | |
| # Ignore code tags inside pre blocks if handle_code_in_pre is False | |
| return | |
| if start: | |
| self.o('`') # Markdown inline code start | |
| self.inside_code = True | |
| else: | |
| self.o('`') # Markdown inline code end | |
| self.inside_code = False | |
| else: | |
| super().handle_tag(tag, attrs, start) | |
| def handle_data(self, data, entity_char=False): | |
| """Override handle_data to capture content within preserved tags.""" | |
| if self.preserve_depth > 0: | |
| self.preserved_content.append(data) | |
| return | |
| if self.inside_pre: | |
| # Output the raw content for pre blocks, including content inside code tags | |
| self.o(data) # Directly output the data as-is (preserve newlines) | |
| return | |
| if self.inside_code: | |
| # Inline code: no newlines allowed | |
| self.o(data.replace('\n', ' ')) | |
| return | |
| # Default behavior for other tags | |
| super().handle_data(data, entity_char) | |
| # # Handle pre tags | |
| # if tag == 'pre': | |
| # if start: | |
| # self.o('```\n') | |
| # self.inside_pre = True | |
| # else: | |
| # self.o('\n```') | |
| # self.inside_pre = False | |
| # # elif tag in ["h1", "h2", "h3", "h4", "h5", "h6"]: | |
| # # pass | |
| # else: | |
| # super().handle_tag(tag, attrs, start) | |
| # def handle_data(self, data, entity_char=False): | |
| # """Override handle_data to capture content within preserved tags.""" | |
| # if self.preserve_depth > 0: | |
| # self.preserved_content.append(data) | |
| # return | |
| # super().handle_data(data, entity_char) | |