diff --git a/README.rst b/README.rst index 98473c6..c6c6d84 100644 --- a/README.rst +++ b/README.rst @@ -139,10 +139,23 @@ keep_inline_images_in that should be allowed to contain inline images, for example ``['td']``. Defaults to an empty list. +table_infer_header + Controls handling of tables with no header row (as indicated by ```` + or ````). When set to ``True``, the first body row is used as the header row. + Defaults to ``False``, which leaves the header row empty. + wrap, wrap_width If ``wrap`` is set to ``True``, all text paragraphs are wrapped at ``wrap_width`` characters. Defaults to ``False`` and ``80``. Use with ``newline_style=BACKSLASH`` to keep line breaks in paragraphs. + A `wrap_width` value of `None` reflows lines to unlimited line length. + +strip_document + Controls whether leading and/or trailing separation newlines are removed from + the final converted document. Supported values are ``LSTRIP`` (leading), + ``RSTRIP`` (trailing), ``STRIP`` (both), and ``None`` (neither). Newlines + within the document are unaffected. + Defaults to ``STRIP``. Options may be specified as kwargs to the ``markdownify`` function, or as a nested ``Options`` class in ``MarkdownConverter`` subclasses. @@ -167,7 +180,7 @@ If you have a special usecase that calls for a special conversion, you can always inherit from ``MarkdownConverter`` and override the method you want to change. The function that handles a HTML tag named ``abc`` is called -``convert_abc(self, el, text, convert_as_inline)`` and returns a string +``convert_abc(self, el, text, parent_tags)`` and returns a string containing the converted HTML tag. The ``MarkdownConverter`` object will handle the conversion based on the function names: @@ -180,8 +193,8 @@ function names: """ Create a custom MarkdownConverter that adds two newlines after an image """ - def convert_img(self, el, text, convert_as_inline): - return super().convert_img(el, text, convert_as_inline) + '\n\n' + def convert_img(self, el, text, parent_tags): + return super().convert_img(el, text, parent_tags) + '\n\n' # Create shorthand method for conversion def md(html, **options): @@ -195,7 +208,7 @@ function names: """ Create a custom MarkdownConverter that ignores paragraphs """ - def convert_p(self, el, text, convert_as_inline): + def convert_p(self, el, text, parent_tags): return '' # Create shorthand method for conversion diff --git a/markdownify/__init__.py b/markdownify/__init__.py index 3272ce5..5d21506 100644 --- a/markdownify/__init__.py +++ b/markdownify/__init__.py @@ -1,16 +1,41 @@ -from bs4 import BeautifulSoup, NavigableString, Comment, Doctype +from bs4 import BeautifulSoup, Comment, Doctype, NavigableString, Tag from textwrap import fill import re import six -convert_heading_re = re.compile(r'convert_h(\d+)') -line_beginning_re = re.compile(r'^', re.MULTILINE) -whitespace_re = re.compile(r'[\t ]+') -all_whitespace_re = re.compile(r'[\t \r\n]+') -newline_whitespace_re = re.compile(r'[\t \r\n]*[\r\n][\t \r\n]*') -html_heading_re = re.compile(r'h[1-6]') +# General-purpose regex patterns +re_convert_heading = re.compile(r'convert_h(\d+)') +re_line_with_content = re.compile(r'^(.*)', flags=re.MULTILINE) +re_whitespace = re.compile(r'[\t ]+') +re_all_whitespace = re.compile(r'[\t \r\n]+') +re_newline_whitespace = re.compile(r'[\t \r\n]*[\r\n][\t \r\n]*') +re_html_heading = re.compile(r'h(\d+)') +# Pattern for creating convert_ function names from tag names +re_make_convert_fn_name = re.compile(r'[\[\]:-]') + +# Extract (leading_nl, content, trailing_nl) from a string +# (functionally equivalent to r'^(\n*)(.*?)(\n*)$', but greedy is faster than reluctant here) +re_extract_newlines = re.compile(r'^(\n*)((?:.*[^\n])?)(\n*)$', flags=re.DOTALL) + +# Escape miscellaneous special Markdown characters +re_escape_misc_chars = re.compile(r'([]\\&<`[>~=+|])') + +# Escape sequence of one or more consecutive '-', preceded +# and followed by whitespace or start/end of fragment, as it +# might be confused with an underline of a header, or with a +# list marker +re_escape_misc_dash_sequences = re.compile(r'(\s|^)(-+(?:\s|$))') + +# Escape sequence of up to six consecutive '#', preceded +# and followed by whitespace or start/end of fragment, as +# it might be confused with an ATX heading +re_escape_misc_hashes = re.compile(r'(\s|^)(#{1,6}(?:\s|$))') + +# Escape '.' or ')' preceded by up to nine digits, as it might be +# confused with a list item +re_escape_misc_list_items = re.compile(r'((?:\s|^)[0-9]{1,9})([.)](?:\s|$))') # Heading styles ATX = 'atx' @@ -26,6 +51,11 @@ ASTERISK = '*' UNDERSCORE = '_' +# Document strip styles +LSTRIP = 'lstrip' +RSTRIP = 'rstrip' +STRIP = 'strip' + def chomp(text): """ @@ -48,13 +78,13 @@ def abstract_inline_conversion(markup_fn): the text if it looks like an HTML tag. markup_fn is necessary to allow for references to self.strong_em_symbol etc. """ - def implementation(self, el, text, convert_as_inline): + def implementation(self, el, text, parent_tags): markup_prefix = markup_fn(self) if markup_prefix.startswith('<') and markup_prefix.endswith('>'): markup_suffix = '), ignore adjacent whitespace elements. + return True + elif should_remove_whitespace_outside(el.previous_sibling) or should_remove_whitespace_outside(el.next_sibling): + # Outside block elements (including
), ignore adjacent whitespace elements.
+                    return True
+                else:
+                    return False
+            elif el is None:
+                return True
             else:
-                text_strip = text.rstrip('\n')
-                newlines_left = len(text) - len(text_strip)
-                next_text = self.process_tag(el, convert_children_as_inline)
-                next_text_strip = next_text.lstrip('\n')
-                newlines_right = len(next_text) - len(next_text_strip)
-                newlines = '\n' * max(newlines_left, newlines_right)
-                text = text_strip + newlines + next_text_strip
-
-        if not children_only:
-            convert_fn = getattr(self, 'convert_%s' % node.name, None)
-            if convert_fn and self.should_convert_tag(node.name):
-                text = convert_fn(node, text, convert_as_inline)
+                raise ValueError('Unexpected element type: %s' % type(el))
+
+        children_to_convert = [el for el in node.children if not _can_ignore(el)]
+
+        # Create a copy of this tag's parent context, then update it to include this tag
+        # to propagate down into the children.
+        parent_tags_for_children = set(parent_tags)
+        parent_tags_for_children.add(node.name)
+
+        # if this tag is a heading or table cell, add an '_inline' parent pseudo-tag
+        if (
+            re_html_heading.match(node.name) is not None  # headings
+            or node.name in {'td', 'th'}  # table cells
+        ):
+            parent_tags_for_children.add('_inline')
+
+        # if this tag is a preformatted element, add a '_noformat' parent pseudo-tag
+        if node.name in {'pre', 'code', 'kbd', 'samp'}:
+            parent_tags_for_children.add('_noformat')
+
+        # Convert the children elements into a list of result strings.
+        child_strings = [
+            self.process_element(el, parent_tags=parent_tags_for_children)
+            for el in children_to_convert
+        ]
+
+        # Remove empty string values.
+        child_strings = [s for s in child_strings if s]
+
+        # Collapse newlines at child element boundaries, if needed.
+        if node.name == 'pre' or node.find_parent('pre'):
+            # Inside 
 blocks, do not collapse newlines.
+            pass
+        else:
+            # Collapse newlines at child element boundaries.
+            updated_child_strings = ['']  # so the first lookback works
+            for child_string in child_strings:
+                # Separate the leading/trailing newlines from the content.
+                leading_nl, content, trailing_nl = re_extract_newlines.match(child_string).groups()
+
+                # If the last child had trailing newlines and this child has leading newlines,
+                # use the larger newline count, limited to 2.
+                if updated_child_strings[-1] and leading_nl:
+                    prev_trailing_nl = updated_child_strings.pop()  # will be replaced by the collapsed value
+                    num_newlines = min(2, max(len(prev_trailing_nl), len(leading_nl)))
+                    leading_nl = '\n' * num_newlines
+
+                # Add the results to the updated child string list.
+                updated_child_strings.extend([leading_nl, content, trailing_nl])
+
+            child_strings = updated_child_strings
+
+        # Join all child text strings into a single string.
+        text = ''.join(child_strings)
+
+        # apply this tag's final conversion function
+        convert_fn = self.get_conv_fn_cached(node.name)
+        if convert_fn is not None:
+            text = convert_fn(node, text, parent_tags=parent_tags)
+
+        return text
+
+    def convert__document_(self, el, text, parent_tags):
+        """Final document-level formatting for BeautifulSoup object (node.name == "[document]")"""
+        if self.options['strip_document'] == LSTRIP:
+            text = text.lstrip('\n')  # remove leading separation newlines
+        elif self.options['strip_document'] == RSTRIP:
+            text = text.rstrip('\n')  # remove trailing separation newlines
+        elif self.options['strip_document'] == STRIP:
+            text = text.strip('\n')  # remove leading and trailing separation newlines
+        elif self.options['strip_document'] is None:
+            pass  # leave leading and trailing separation newlines as-is
+        else:
+            raise ValueError('Invalid value for strip_document: %s' % self.options['strip_document'])
 
         return text
 
-    def process_text(self, el):
+    def process_text(self, el, parent_tags=None):
+        # For the top-level element, initialize the parent context with an empty set.
+        if parent_tags is None:
+            parent_tags = set()
+
         text = six.text_type(el) or ''
 
         # normalize whitespace if we're not inside a preformatted element
-        if not el.find_parent('pre'):
+        if 'pre' not in parent_tags:
             if self.options['wrap']:
-                text = all_whitespace_re.sub(' ', text)
+                text = re_all_whitespace.sub(' ', text)
             else:
-                text = newline_whitespace_re.sub('\n', text)
-                text = whitespace_re.sub(' ', text)
+                text = re_newline_whitespace.sub('\n', text)
+                text = re_whitespace.sub(' ', text)
 
         # escape special characters if we're not inside a preformatted or code element
-        if not el.find_parent(['pre', 'code', 'kbd', 'samp']):
-            text = self.escape(text)
+        if '_noformat' not in parent_tags:
+            text = self.escape(text, parent_tags)
 
         # remove leading whitespace at the start or just after a
         # block-level element; remove traliing whitespace at the end
@@ -198,7 +336,7 @@ def process_text(self, el):
         if (should_remove_whitespace_outside(el.previous_sibling)
                 or (should_remove_whitespace_inside(el.parent)
                     and not el.previous_sibling)):
-            text = text.lstrip()
+            text = text.lstrip(' \t\r\n')
         if (should_remove_whitespace_outside(el.next_sibling)
                 or (should_remove_whitespace_inside(el.parent)
                     and not el.next_sibling)):
@@ -206,23 +344,36 @@ def process_text(self, el):
 
         return text
 
-    def __getattr__(self, attr):
-        # Handle headings
-        m = convert_heading_re.match(attr)
-        if m:
-            n = int(m.group(1))
+    def get_conv_fn_cached(self, tag_name):
+        """Given a tag name, return the conversion function using the cache."""
+        # If conversion function is not in cache, add it
+        if tag_name not in self.convert_fn_cache:
+            self.convert_fn_cache[tag_name] = self.get_conv_fn(tag_name)
+
+        # Return the cached entry
+        return self.convert_fn_cache[tag_name]
 
-            def convert_tag(el, text, convert_as_inline):
-                return self._convert_hn(n, el, text, convert_as_inline)
+    def get_conv_fn(self, tag_name):
+        """Given a tag name, find and return the conversion function."""
+        tag_name = tag_name.lower()
 
-            convert_tag.__name__ = 'convert_h%s' % n
-            setattr(self, convert_tag.__name__, convert_tag)
-            return convert_tag
+        # Handle strip/convert exclusion options
+        if not self.should_convert_tag(tag_name):
+            return None
 
-        raise AttributeError(attr)
+        # Handle headings with _convert_hn() function
+        match = re_html_heading.match(tag_name)
+        if match:
+            n = int(match.group(1))
+            return lambda el, text, parent_tags: self._convert_hn(n, el, text, parent_tags)
+
+        # For other tags, look up their conversion function by tag name
+        convert_fn_name = "convert_%s" % re_make_convert_fn_name.sub('_', tag_name)
+        convert_fn = getattr(self, convert_fn_name, None)
+        return convert_fn
 
     def should_convert_tag(self, tag):
-        tag = tag.lower()
+        """Given a tag name, return whether to convert based on strip/convert options."""
         strip = self.options['strip']
         convert = self.options['convert']
         if strip is not None:
@@ -232,38 +383,28 @@ def should_convert_tag(self, tag):
         else:
             return True
 
-    def escape(self, text):
+    def escape(self, text, parent_tags):
         if not text:
             return ''
         if self.options['escape_misc']:
-            text = re.sub(r'([\\&<`[>~=+|])', r'\\\1', text)
-            # A sequence of one or more consecutive '-', preceded and
-            # followed by whitespace or start/end of fragment, might
-            # be confused with an underline of a header, or with a
-            # list marker.
-            text = re.sub(r'(\s|^)(-+(?:\s|$))', r'\1\\\2', text)
-            # A sequence of up to six consecutive '#', preceded and
-            # followed by whitespace or start/end of fragment, might
-            # be confused with an ATX heading.
-            text = re.sub(r'(\s|^)(#{1,6}(?:\s|$))', r'\1\\\2', text)
-            # '.' or ')' preceded by up to nine digits might be
-            # confused with a list item.
-            text = re.sub(r'((?:\s|^)[0-9]{1,9})([.)](?:\s|$))', r'\1\\\2',
-                          text)
+            text = re_escape_misc_chars.sub(r'\\\1', text)
+            text = re_escape_misc_dash_sequences.sub(r'\1\\\2', text)
+            text = re_escape_misc_hashes.sub(r'\1\\\2', text)
+            text = re_escape_misc_list_items.sub(r'\1\\\2', text)
+
         if self.options['escape_asterisks']:
             text = text.replace('*', r'\*')
         if self.options['escape_underscores']:
             text = text.replace('_', r'\_')
         return text
 
-    def indent(self, text, columns):
-        return line_beginning_re.sub(' ' * columns, text) if text else ''
-
     def underline(self, text, pad_char):
         text = (text or '').rstrip()
         return '\n\n%s\n%s\n\n' % (text, pad_char * len(text)) if text else ''
 
-    def convert_a(self, el, text, convert_as_inline):
+    def convert_a(self, el, text, parent_tags):
+        if '_noformat' in parent_tags:
+            return text
         prefix, suffix, text = chomp(text)
         if not text:
             return ''
@@ -283,15 +424,24 @@ def convert_a(self, el, text, convert_as_inline):
 
     convert_b = abstract_inline_conversion(lambda self: 2 * self.options['strong_em_symbol'])
 
-    def convert_blockquote(self, el, text, convert_as_inline):
+    def convert_blockquote(self, el, text, parent_tags):
+        # handle some early-exit scenarios
+        text = (text or '').strip(' \t\r\n')
+        if '_inline' in parent_tags:
+            return ' ' + text + ' '
+        if not text:
+            return "\n"
 
-        if convert_as_inline:
-            return ' ' + text.strip() + ' '
+        # indent lines with blockquote marker
+        def _indent_for_blockquote(match):
+            line_content = match.group(1)
+            return '> ' + line_content if line_content else '>'
+        text = re_line_with_content.sub(_indent_for_blockquote, text)
 
-        return '\n' + (line_beginning_re.sub('> ', text.strip()) + '\n\n') if text else ''
+        return '\n' + text + '\n\n'
 
-    def convert_br(self, el, text, convert_as_inline):
-        if convert_as_inline:
+    def convert_br(self, el, text, parent_tags):
+        if '_inline' in parent_tags:
             return ""
 
         if self.options['newline_style'].lower() == BACKSLASH:
@@ -299,21 +449,63 @@ def convert_br(self, el, text, convert_as_inline):
         else:
             return '  \n'
 
-    def convert_code(self, el, text, convert_as_inline):
-        if el.parent.name == 'pre':
+    def convert_code(self, el, text, parent_tags):
+        if 'pre' in parent_tags:
             return text
         converter = abstract_inline_conversion(lambda self: '`')
-        return converter(self, el, text, convert_as_inline)
+        return converter(self, el, text, parent_tags)
 
     convert_del = abstract_inline_conversion(lambda self: '~~')
 
+    def convert_div(self, el, text, parent_tags):
+        if '_inline' in parent_tags:
+            return ' ' + text.strip() + ' '
+        text = text.strip()
+        return '\n\n%s\n\n' % text if text else ''
+
+    convert_article = convert_div
+
+    convert_section = convert_div
+
     convert_em = abstract_inline_conversion(lambda self: self.options['strong_em_symbol'])
 
     convert_kbd = convert_code
 
-    def _convert_hn(self, n, el, text, convert_as_inline):
+    def convert_dd(self, el, text, parent_tags):
+        text = (text or '').strip()
+        if '_inline' in parent_tags:
+            return ' ' + text + ' '
+        if not text:
+            return '\n'
+
+        # indent definition content lines by four spaces
+        def _indent_for_dd(match):
+            line_content = match.group(1)
+            return '    ' + line_content if line_content else ''
+        text = re_line_with_content.sub(_indent_for_dd, text)
+
+        # insert definition marker into first-line indent whitespace
+        text = ':' + text[1:]
+
+        return '%s\n' % text
+
+    def convert_dt(self, el, text, parent_tags):
+        # remove newlines from term text
+        text = (text or '').strip()
+        text = re_all_whitespace.sub(' ', text)
+        if '_inline' in parent_tags:
+            return ' ' + text + ' '
+        if not text:
+            return '\n'
+
+        # TODO - format consecutive 
elements as directly adjacent lines): + # https://michelf.ca/projects/php-markdown/extra/#def-list + + return '\n%s\n' % text + + def _convert_hn(self, n, el, text, parent_tags): """ Method name prefixed with _ to prevent to call this """ - if convert_as_inline: + if '_inline' in parent_tags: return text # prevent MemoryErrors in case of very large n @@ -324,58 +516,59 @@ def _convert_hn(self, n, el, text, convert_as_inline): if style == UNDERLINED and n <= 2: line = '=' if n == 1 else '-' return self.underline(text, line) - text = all_whitespace_re.sub(' ', text) + text = re_all_whitespace.sub(' ', text) hashes = '#' * n if style == ATX_CLOSED: - return '\n%s %s %s\n\n' % (hashes, text, hashes) - return '\n%s %s\n\n' % (hashes, text) + return '\n\n%s %s %s\n\n' % (hashes, text, hashes) + return '\n\n%s %s\n\n' % (hashes, text) - def convert_hr(self, el, text, convert_as_inline): + def convert_hr(self, el, text, parent_tags): return '\n\n---\n\n' convert_i = convert_em - def convert_img(self, el, text, convert_as_inline): + def convert_img(self, el, text, parent_tags): alt = el.attrs.get('alt', None) or '' src = el.attrs.get('src', None) or '' title = el.attrs.get('title', None) or '' title_part = ' "%s"' % title.replace('"', r'\"') if title else '' - if (convert_as_inline + if ('_inline' in parent_tags and el.parent.name not in self.options['keep_inline_images_in']): return alt return '![%s](%s%s)' % (alt, src, title_part) - def convert_list(self, el, text, convert_as_inline): + def convert_list(self, el, text, parent_tags): # Converting a list to inline is undefined. - # Ignoring convert_to_inline for list. + # Ignoring inline conversion parents for list. - nested = False before_paragraph = False - if el.next_sibling and el.next_sibling.name not in ['ul', 'ol']: + next_sibling = _next_block_content_sibling(el) + if next_sibling and next_sibling.name not in ['ul', 'ol']: before_paragraph = True - while el: - if el.name == 'li': - nested = True - break - el = el.parent - if nested: - # remove trailing newline if nested + if 'li' in parent_tags: + # remove trailing newline if we're in a nested list return '\n' + text.rstrip() return '\n\n' + text + ('\n' if before_paragraph else '') convert_ul = convert_list convert_ol = convert_list - def convert_li(self, el, text, convert_as_inline): + def convert_li(self, el, text, parent_tags): + # handle some early-exit scenarios + text = (text or '').strip() + if not text: + return "\n" + + # determine list item bullet character to use parent = el.parent if parent is not None and parent.name == 'ol': if parent.get("start") and str(parent.get("start")).isnumeric(): start = int(parent.get("start")) else: start = 1 - bullet = '%s.' % (start + parent.index(el)) + bullet = '%s.' % (start + len(el.find_previous_siblings('li'))) else: depth = -1 while el: @@ -385,34 +578,44 @@ def convert_li(self, el, text, convert_as_inline): bullets = self.options['bullets'] bullet = bullets[depth % len(bullets)] bullet = bullet + ' ' - text = (text or '').strip() - text = self.indent(text, len(bullet)) - if text: - text = bullet + text[len(bullet):] + bullet_width = len(bullet) + bullet_indent = ' ' * bullet_width + + # indent content lines by bullet width + def _indent_for_li(match): + line_content = match.group(1) + return bullet_indent + line_content if line_content else '' + text = re_line_with_content.sub(_indent_for_li, text) + + # insert bullet into first-line indent whitespace + text = bullet + text[bullet_width:] + return '%s\n' % text - def convert_p(self, el, text, convert_as_inline): - if convert_as_inline: - return ' ' + text.strip() + ' ' + def convert_p(self, el, text, parent_tags): + if '_inline' in parent_tags: + return ' ' + text.strip(' \t\r\n') + ' ' + text = text.strip(' \t\r\n') if self.options['wrap']: # Preserve newlines (and preceding whitespace) resulting # from
tags. Newlines in the input have already been # replaced by spaces. - lines = text.split('\n') - new_lines = [] - for line in lines: - line = line.lstrip() - line_no_trailing = line.rstrip() - trailing = line[len(line_no_trailing):] - line = fill(line, - width=self.options['wrap_width'], - break_long_words=False, - break_on_hyphens=False) - new_lines.append(line + trailing) - text = '\n'.join(new_lines) + if self.options['wrap_width'] is not None: + lines = text.split('\n') + new_lines = [] + for line in lines: + line = line.lstrip(' \t\r\n') + line_no_trailing = line.rstrip() + trailing = line[len(line_no_trailing):] + line = fill(line, + width=self.options['wrap_width'], + break_long_words=False, + break_on_hyphens=False) + new_lines.append(line + trailing) + text = '\n'.join(new_lines) return '\n\n%s\n\n' % text if text else '' - def convert_pre(self, el, text, convert_as_inline): + def convert_pre(self, el, text, parent_tags): if not text: return '' code_language = self.options['code_language'] @@ -420,12 +623,12 @@ def convert_pre(self, el, text, convert_as_inline): if self.options['code_language_callback']: code_language = self.options['code_language_callback'](el) or code_language - return '\n```%s\n%s\n```\n' % (code_language, text) + return '\n\n```%s\n%s\n```\n\n' % (code_language, text) - def convert_script(self, el, text, convert_as_inline): + def convert_script(self, el, text, parent_tags): return '' - def convert_style(self, el, text, convert_as_inline): + def convert_style(self, el, text, parent_tags): return '' convert_s = convert_del @@ -438,38 +641,50 @@ def convert_style(self, el, text, convert_as_inline): convert_sup = abstract_inline_conversion(lambda self: self.options['sup_symbol']) - def convert_table(self, el, text, convert_as_inline): - return '\n\n' + text + '\n' + def convert_table(self, el, text, parent_tags): + return '\n\n' + text.strip() + '\n\n' - def convert_caption(self, el, text, convert_as_inline): - return text + '\n' + def convert_caption(self, el, text, parent_tags): + return text.strip() + '\n\n' - def convert_figcaption(self, el, text, convert_as_inline): - return '\n\n' + text + '\n\n' + def convert_figcaption(self, el, text, parent_tags): + return '\n\n' + text.strip() + '\n\n' - def convert_td(self, el, text, convert_as_inline): + def convert_td(self, el, text, parent_tags): colspan = 1 if 'colspan' in el.attrs and el['colspan'].isdigit(): colspan = int(el['colspan']) return ' ' + text.strip().replace("\n", " ") + ' |' * colspan - def convert_th(self, el, text, convert_as_inline): + def convert_th(self, el, text, parent_tags): colspan = 1 if 'colspan' in el.attrs and el['colspan'].isdigit(): colspan = int(el['colspan']) return ' ' + text.strip().replace("\n", " ") + ' |' * colspan - def convert_tr(self, el, text, convert_as_inline): + def convert_tr(self, el, text, parent_tags): cells = el.find_all(['td', 'th']) + is_first_row = el.find_previous_sibling() is None is_headrow = ( all([cell.name == 'th' for cell in cells]) - or (not el.previous_sibling and not el.parent.name == 'tbody') - or (not el.previous_sibling and el.parent.name == 'tbody' and len(el.parent.parent.find_all(['thead'])) < 1) + or (el.parent.name == 'thead' + # avoid multiple tr in thead + and len(el.parent.find_all('tr')) == 1) + ) + is_head_row_missing = ( + (is_first_row and not el.parent.name == 'tbody') + or (is_first_row and el.parent.name == 'tbody' and len(el.parent.parent.find_all(['thead'])) < 1) ) overline = '' underline = '' - if is_headrow and not el.previous_sibling: - # first row and is headline: print headline underline + if ((is_headrow + or (is_head_row_missing + and self.options['table_infer_header'])) + and is_first_row): + # first row and: + # - is headline or + # - headline is missing and header inference is enabled + # print headline underline full_colspan = 0 for cell in cells: if 'colspan' in cell.attrs and cell['colspan'].isdigit(): @@ -477,13 +692,16 @@ def convert_tr(self, el, text, convert_as_inline): else: full_colspan += 1 underline += '| ' + ' | '.join(['---'] * full_colspan) + ' |' + '\n' - elif (not el.previous_sibling - and (el.parent.name == 'table' - or (el.parent.name == 'tbody' - and not el.parent.previous_sibling))): + elif ((is_head_row_missing + and not self.options['table_infer_header']) + or (is_first_row + and (el.parent.name == 'table' + or (el.parent.name == 'tbody' + and not el.parent.find_previous_sibling())))): + # headline is missing and header inference is disabled or: # first row, not headline, and: - # - the parent is table or - # - the parent is tbody at the beginning of a table. + # - the parent is table or + # - the parent is tbody at the beginning of a table. # print empty headline above this row overline += '| ' + ' | '.join([''] * len(cells)) + ' |' + '\n' overline += '| ' + ' | '.join(['---'] * len(cells)) + ' |' + '\n' diff --git a/markdownify/main.py b/markdownify/main.py index 4e1c874..432efb5 100644 --- a/markdownify/main.py +++ b/markdownify/main.py @@ -61,6 +61,10 @@ def main(argv=sys.argv[1:]): "should be converted to markdown images instead, this option can " "be set to a list of parent tags that should be allowed to " "contain inline images.") + parser.add_argument('--table-infer-header', dest='table_infer_header', + action='store_true', + help="When a table has no header row (as indicated by '' " + "or ''), use the first body row as the header row.") parser.add_argument('-w', '--wrap', action='store_true', help="Wrap all text paragraphs at --wrap-width characters.") parser.add_argument('--wrap-width', type=int, default=80) diff --git a/pyproject.toml b/pyproject.toml index e5ce8cb..a6ae3e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "markdownify" -version = "0.14.1" +version = "1.0.0" authors = [{name = "Matthew Tretter", email = "m@tthewwithanm.com"}] description = "Convert HTML to markdown." readme = "README.rst" diff --git a/tests/test_advanced.py b/tests/test_advanced.py index a3a5fda..6123d8c 100644 --- a/tests/test_advanced.py +++ b/tests/test_advanced.py @@ -1,4 +1,4 @@ -from markdownify import markdownify as md +from .utils import md def test_chomp(): diff --git a/tests/test_args.py b/tests/test_args.py index ebce4a8..301c19f 100644 --- a/tests/test_args.py +++ b/tests/test_args.py @@ -2,7 +2,8 @@ Test whitelisting/blacklisting of specific tags. """ -from markdownify import markdownify as md +from markdownify import markdownify, LSTRIP, RSTRIP, STRIP +from .utils import md def test_strip(): @@ -23,3 +24,11 @@ def test_convert(): def test_do_not_convert(): text = md('Some Text', convert=[]) assert text == 'Some Text' + + +def test_strip_document(): + assert markdownify("

Hello

") == "Hello" # test default of STRIP + assert markdownify("

Hello

", strip_document=LSTRIP) == "Hello\n\n" + assert markdownify("

Hello

", strip_document=RSTRIP) == "\n\nHello" + assert markdownify("

Hello

", strip_document=STRIP) == "Hello" + assert markdownify("

Hello

", strip_document=None) == "\n\nHello\n\n" diff --git a/tests/test_basic.py b/tests/test_basic.py index 66f8b6c..9be524e 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -1,4 +1,4 @@ -from markdownify import markdownify as md +from .utils import md def test_single_tag(): @@ -6,7 +6,7 @@ def test_single_tag(): def test_soup(): - assert md('
Hello
') == 'Hello' + assert md('
Hello
') == '\n\nHello\n\n' def test_whitespace(): diff --git a/tests/test_conversions.py b/tests/test_conversions.py index 2283c29..e851ac2 100644 --- a/tests/test_conversions.py +++ b/tests/test_conversions.py @@ -1,4 +1,5 @@ -from markdownify import markdownify as md, ATX, ATX_CLOSED, BACKSLASH, SPACES, UNDERSCORE +from markdownify import ATX, ATX_CLOSED, BACKSLASH, SPACES, UNDERSCORE +from .utils import md def inline_tests(tag, markup): @@ -39,6 +40,11 @@ def test_a_no_autolinks(): assert md('https://google.com', autolinks=False) == '[https://google.com](https://google.com)' +def test_a_in_code(): + assert md('Google') == '`Google`' + assert md('
Google
') == '\n\n```\nGoogle\n```\n\n' + + def test_b(): assert md('Hello') == '**Hello**' @@ -53,11 +59,12 @@ def test_b_spaces(): def test_blockquote(): assert md('
Hello
') == '\n> Hello\n\n' assert md('
\nHello\n
') == '\n> Hello\n\n' + assert md('
 Hello
') == '\n> \u00a0Hello\n\n' def test_blockquote_with_nested_paragraph(): assert md('

Hello

') == '\n> Hello\n\n' - assert md('

Hello

Hello again

') == '\n> Hello\n> \n> Hello again\n\n' + assert md('

Hello

Hello again

') == '\n> Hello\n>\n> Hello again\n\n' def test_blockquote_with_paragraph(): @@ -74,11 +81,6 @@ def test_br(): assert md('a
b
c', newline_style=BACKSLASH) == 'a\\\nb\\\nc' -def test_caption(): - assert md('TEXT
Caption
SPAN
') == 'TEXT\n\nCaption\n\nSPAN' - assert md('
SPAN
Caption
TEXT') == 'SPAN\n\nCaption\n\nTEXT' - - def test_code(): inline_tests('code', '`') assert md('*this_should_not_escape*') == '`*this_should_not_escape*`' @@ -99,25 +101,51 @@ def test_code(): assert md('foobarbaz', sub_symbol='^') == '`foobarbaz`' +def test_dl(): + assert md('
term
definition
') == '\nterm\n: definition\n' + assert md('

te

rm

definition
') == '\nte rm\n: definition\n' + assert md('
term

definition-p1

definition-p2

') == '\nterm\n: definition-p1\n\n definition-p2\n' + assert md('
term

definition 1

definition 2

') == '\nterm\n: definition 1\n: definition 2\n' + assert md('
term 1
definition 1
term 2
definition 2
') == '\nterm 1\n: definition 1\nterm 2\n: definition 2\n' + assert md('
term

line 1

line 2

') == '\nterm\n: > line 1\n >\n > line 2\n' + assert md('
term
  1. 1

    • 2a
    • 2b
  2. 3

') == '\nterm\n: 1. 1\n\n * 2a\n * 2b\n 2. 3\n' + + def test_del(): inline_tests('del', '~~') -def test_div(): - assert md('Hello World') == 'Hello World' +def test_div_section_article(): + for tag in ['div', 'section', 'article']: + assert md(f'<{tag}>456') == '\n\n456\n\n' + assert md(f'123<{tag}>456789') == '123\n\n456\n\n789' + assert md(f'123<{tag}>\n 456 \n789') == '123\n\n456\n\n789' + assert md(f'123<{tag}>

456

789') == '123\n\n456\n\n789' + assert md(f'123<{tag}>\n

456

\n789') == '123\n\n456\n\n789' + assert md(f'123<{tag}>
4 5 6
789') == '123\n\n```\n4 5 6\n```\n\n789' + assert md(f'123<{tag}>\n
4 5 6
\n789') == '123\n\n```\n4 5 6\n```\n\n789' + assert md(f'123<{tag}>4\n5\n6789') == '123\n\n4\n5\n6\n\n789' + assert md(f'123<{tag}>\n4\n5\n6\n789') == '123\n\n4\n5\n6\n\n789' + assert md(f'123<{tag}>\n

\n4\n5\n6\n

\n789') == '123\n\n4\n5\n6\n\n789' + assert md(f'<{tag}>

title

body', heading_style=ATX) == '\n\n# title\n\nbody\n\n' def test_em(): inline_tests('em', '*') +def test_figcaption(): + assert (md("TEXT
\nCaption\n
SPAN
") == "TEXT\n\nCaption\n\nSPAN") + assert (md("
SPAN
\nCaption\n
TEXT") == "SPAN\n\nCaption\n\nTEXT") + + def test_header_with_space(): - assert md('

\n\nHello

') == '\n### Hello\n\n' - assert md('

Hello\n\n\nWorld

') == '\n### Hello World\n\n' - assert md('

\n\nHello

') == '\n#### Hello\n\n' - assert md('
\n\nHello
') == '\n##### Hello\n\n' - assert md('
\n\nHello\n\n
') == '\n##### Hello\n\n' - assert md('
\n\nHello \n\n
') == '\n##### Hello\n\n' + assert md('

\n\nHello

') == '\n\n### Hello\n\n' + assert md('

Hello\n\n\nWorld

') == '\n\n### Hello World\n\n' + assert md('

\n\nHello

') == '\n\n#### Hello\n\n' + assert md('
\n\nHello
') == '\n\n##### Hello\n\n' + assert md('
\n\nHello\n\n
') == '\n\n##### Hello\n\n' + assert md('
\n\nHello \n\n
') == '\n\n##### Hello\n\n' def test_h1(): @@ -129,24 +157,24 @@ def test_h2(): def test_hn(): - assert md('

Hello

') == '\n### Hello\n\n' - assert md('

Hello

') == '\n#### Hello\n\n' - assert md('
Hello
') == '\n##### Hello\n\n' - assert md('
Hello
') == '\n###### Hello\n\n' + assert md('

Hello

') == '\n\n### Hello\n\n' + assert md('

Hello

') == '\n\n#### Hello\n\n' + assert md('
Hello
') == '\n\n##### Hello\n\n' + assert md('
Hello
') == '\n\n###### Hello\n\n' assert md('Hello') == md('
Hello
') assert md('Hello') == md('Hello') def test_hn_chained(): - assert md('

First

\n

Second

\n

Third

', heading_style=ATX) == '\n# First\n\n## Second\n\n### Third\n\n' - assert md('X

First

', heading_style=ATX) == 'X\n# First\n\n' - assert md('X

First

', heading_style=ATX_CLOSED) == 'X\n# First #\n\n' + assert md('

First

\n

Second

\n

Third

', heading_style=ATX) == '\n\n# First\n\n## Second\n\n### Third\n\n' + assert md('X

First

', heading_style=ATX) == 'X\n\n# First\n\n' + assert md('X

First

', heading_style=ATX_CLOSED) == 'X\n\n# First #\n\n' assert md('X

First

') == 'X\n\nFirst\n=====\n\n' def test_hn_nested_tag_heading_style(): - assert md('

A

P

C

', heading_style=ATX_CLOSED) == '\n# A P C #\n\n' - assert md('

A

P

C

', heading_style=ATX) == '\n# A P C\n\n' + assert md('

A

P

C

', heading_style=ATX_CLOSED) == '\n\n# A P C #\n\n' + assert md('

A

P

C

', heading_style=ATX) == '\n\n# A P C\n\n' def test_hn_nested_simple_tag(): @@ -162,9 +190,9 @@ def test_hn_nested_simple_tag(): ] for tag, markdown in tag_to_markdown: - assert md('

A <' + tag + '>' + tag + ' B

') == '\n### A ' + markdown + ' B\n\n' + assert md('

A <' + tag + '>' + tag + ' B

') == '\n\n### A ' + markdown + ' B\n\n' - assert md('

A
B

', heading_style=ATX) == '\n### A B\n\n' + assert md('

A
B

', heading_style=ATX) == '\n\n### A B\n\n' # Nested lists not supported # assert md('

A
  • li1
  • l2

', heading_style=ATX) == '\n### A li1 li2 B\n\n' @@ -177,18 +205,23 @@ def test_hn_nested_img(): ("alt='Alt Text' title='Optional title'", "Alt Text", " \"Optional title\""), ] for image_attributes, markdown, title in image_attributes_to_markdown: - assert md('

A B

') == '\n### A' + (' ' + markdown + ' ' if markdown else ' ') + 'B\n\n' - assert md('

A B

', keep_inline_images_in=['h3']) == '\n### A ![' + markdown + '](/path/to/img.jpg' + title + ') B\n\n' + assert md('

A B

') == '\n\n### A' + (' ' + markdown + ' ' if markdown else ' ') + 'B\n\n' + assert md('

A B

', keep_inline_images_in=['h3']) == '\n\n### A ![' + markdown + '](/path/to/img.jpg' + title + ') B\n\n' def test_hn_atx_headings(): - assert md('

Hello

', heading_style=ATX) == '\n# Hello\n\n' - assert md('

Hello

', heading_style=ATX) == '\n## Hello\n\n' + assert md('

Hello

', heading_style=ATX) == '\n\n# Hello\n\n' + assert md('

Hello

', heading_style=ATX) == '\n\n## Hello\n\n' def test_hn_atx_closed_headings(): - assert md('

Hello

', heading_style=ATX_CLOSED) == '\n# Hello #\n\n' - assert md('

Hello

', heading_style=ATX_CLOSED) == '\n## Hello ##\n\n' + assert md('

Hello

', heading_style=ATX_CLOSED) == '\n\n# Hello #\n\n' + assert md('

Hello

', heading_style=ATX_CLOSED) == '\n\n## Hello ##\n\n' + + +def test_hn_newlines(): + assert md("

H1-1

TEXT

H2-2

TEXT

H1-2

TEXT", heading_style=ATX) == '\n\n# H1-1\n\nTEXT\n\n## H2-2\n\nTEXT\n\n# H1-2\n\nTEXT' + assert md('

H1-1

\n

TEXT

\n

H2-2

\n

TEXT

\n

H1-2

\n

TEXT

', heading_style=ATX) == '\n\n# H1-1\n\nTEXT\n\n## H2-2\n\nTEXT\n\n# H1-2\n\nTEXT\n\n' def test_head(): @@ -216,9 +249,11 @@ def test_kbd(): def test_p(): assert md('

hello

') == '\n\nhello\n\n' + assert md("

hello

") == "\n\nhello\n\n" assert md('

123456789 123456789

') == '\n\n123456789 123456789\n\n' assert md('

123456789\n\n\n123456789

') == '\n\n123456789\n123456789\n\n' assert md('

123456789\n\n\n123456789

', wrap=True, wrap_width=80) == '\n\n123456789 123456789\n\n' + assert md('

123456789\n\n\n123456789

', wrap=True, wrap_width=None) == '\n\n123456789 123456789\n\n' assert md('

123456789 123456789

', wrap=True, wrap_width=10) == '\n\n123456789\n123456789\n\n' assert md('

Some long link

', wrap=True, wrap_width=10) == '\n\n[Some long\nlink](https://example.com)\n\n' assert md('

12345
67890

', wrap=True, wrap_width=10, newline_style=BACKSLASH) == '\n\n12345\\\n67890\n\n' @@ -232,26 +267,31 @@ def test_p(): assert md('

1234 5678 9012
67890

', wrap=True, wrap_width=10, newline_style=BACKSLASH) == '\n\n1234 5678\n9012\\\n67890\n\n' assert md('

1234 5678 9012
67890

', wrap=True, wrap_width=10, newline_style=SPACES) == '\n\n1234 5678\n9012 \n67890\n\n' assert md('First

Second

Third

Fourth') == 'First\n\nSecond\n\nThird\n\nFourth' + assert md('

 x y

', wrap=True, wrap_width=80) == '\n\n\u00a0x y\n\n' def test_pre(): - assert md('
test\n    foo\nbar
') == '\n```\ntest\n foo\nbar\n```\n' - assert md('
test\n    foo\nbar
') == '\n```\ntest\n foo\nbar\n```\n' - assert md('
*this_should_not_escape*
') == '\n```\n*this_should_not_escape*\n```\n' - assert md('
*this_should_not_escape*
') == '\n```\n*this_should_not_escape*\n```\n' - assert md('
\t\tthis  should\t\tnot  normalize
') == '\n```\n\t\tthis should\t\tnot normalize\n```\n' - assert md('
\t\tthis  should\t\tnot  normalize
') == '\n```\n\t\tthis should\t\tnot normalize\n```\n' - assert md('
foo\nbar\nbaz
') == '\n```\nfoo\nbar\nbaz\n```\n' - assert md('
foo\nbar\nbaz
') == '\n```\nfoo\nbar\nbaz\n```\n' - assert md('
foo\nbar\nbaz
') == '\n```\nfoo\nbar\nbaz\n```\n' - assert md('
foo\nbaz
') == '\n```\nfoo\nbaz\n```\n' - assert md('
foo\nbar\nbaz
') == '\n```\nfoo\nbar\nbaz\n```\n' - assert md('
foo\nbar\nbaz
') == '\n```\nfoo\nbar\nbaz\n```\n' - assert md('
foo\nbar\nbaz
') == '\n```\nfoo\nbar\nbaz\n```\n' - assert md('
foo\nbar\nbaz
') == '\n```\nfoo\nbar\nbaz\n```\n' - assert md('
foo\nbar\nbaz
') == '\n```\nfoo\nbar\nbaz\n```\n' - assert md('
foo\nbar\nbaz
', sup_symbol='^') == '\n```\nfoo\nbar\nbaz\n```\n' - assert md('
foo\nbar\nbaz
', sub_symbol='^') == '\n```\nfoo\nbar\nbaz\n```\n' + assert md('
test\n    foo\nbar
') == '\n\n```\ntest\n foo\nbar\n```\n\n' + assert md('
test\n    foo\nbar
') == '\n\n```\ntest\n foo\nbar\n```\n\n' + assert md('
*this_should_not_escape*
') == '\n\n```\n*this_should_not_escape*\n```\n\n' + assert md('
*this_should_not_escape*
') == '\n\n```\n*this_should_not_escape*\n```\n\n' + assert md('
\t\tthis  should\t\tnot  normalize
') == '\n\n```\n\t\tthis should\t\tnot normalize\n```\n\n' + assert md('
\t\tthis  should\t\tnot  normalize
') == '\n\n```\n\t\tthis should\t\tnot normalize\n```\n\n' + assert md('
foo\nbar\nbaz
') == '\n\n```\nfoo\nbar\nbaz\n```\n\n' + assert md('
foo\nbar\nbaz
') == '\n\n```\nfoo\nbar\nbaz\n```\n\n' + assert md('
foo\nbar\nbaz
') == '\n\n```\nfoo\nbar\nbaz\n```\n\n' + assert md('
foo\nbaz
') == '\n\n```\nfoo\nbaz\n```\n\n' + assert md('
foo\nbar\nbaz
') == '\n\n```\nfoo\nbar\nbaz\n```\n\n' + assert md('
foo\nbar\nbaz
') == '\n\n```\nfoo\nbar\nbaz\n```\n\n' + assert md('
foo\nbar\nbaz
') == '\n\n```\nfoo\nbar\nbaz\n```\n\n' + assert md('
foo\nbar\nbaz
') == '\n\n```\nfoo\nbar\nbaz\n```\n\n' + assert md('
foo\nbar\nbaz
') == '\n\n```\nfoo\nbar\nbaz\n```\n\n' + assert md('
foo\nbar\nbaz
', sup_symbol='^') == '\n\n```\nfoo\nbar\nbaz\n```\n\n' + assert md('
foo\nbar\nbaz
', sub_symbol='^') == '\n\n```\nfoo\nbar\nbaz\n```\n\n' + assert md('
foo\nbar\nbaz
', sub_symbol='^') == '\n\n```\nfoo\nbar\nbaz\n```\n\n' + + assert md('foo
bar
baz', sub_symbol='^') == 'foo\n\n```\nbar\n```\n\nbaz' + assert md("

foo

\n
bar
\n

baz

", sub_symbol="^") == "\n\nfoo\n\n```\nbar\n```\n\nbaz" def test_script(): @@ -294,17 +334,17 @@ def test_sup(): def test_lang(): - assert md('
test\n    foo\nbar
', code_language='python') == '\n```python\ntest\n foo\nbar\n```\n' - assert md('
test\n    foo\nbar
', code_language='javascript') == '\n```javascript\ntest\n foo\nbar\n```\n' + assert md('
test\n    foo\nbar
', code_language='python') == '\n\n```python\ntest\n foo\nbar\n```\n\n' + assert md('
test\n    foo\nbar
', code_language='javascript') == '\n\n```javascript\ntest\n foo\nbar\n```\n\n' def test_lang_callback(): def callback(el): return el['class'][0] if el.has_attr('class') else None - assert md('
test\n    foo\nbar
', code_language_callback=callback) == '\n```python\ntest\n foo\nbar\n```\n' - assert md('
test\n    foo\nbar
', code_language_callback=callback) == '\n```javascript\ntest\n foo\nbar\n```\n' - assert md('
test\n    foo\nbar
', code_language_callback=callback) == '\n```javascript\ntest\n foo\nbar\n```\n' + assert md('
test\n    foo\nbar
', code_language_callback=callback) == '\n\n```python\ntest\n foo\nbar\n```\n\n' + assert md('
test\n    foo\nbar
', code_language_callback=callback) == '\n\n```javascript\ntest\n foo\nbar\n```\n\n' + assert md('
test\n    foo\nbar
', code_language_callback=callback) == '\n\n```javascript\ntest\n foo\nbar\n```\n\n' def test_spaces(): @@ -314,4 +354,4 @@ def test_spaces(): assert md('test
text
after') == 'test\n> text\n\nafter' assert md('
  1. x
  2. y
') == '\n\n1. x\n2. y\n' assert md('
  • x
  • y
  • ') == '\n\n* x\n* y\n' - assert md('test
     foo 
    bar') == 'test\n```\n foo \n```\nbar' + assert md('test
     foo 
    bar') == 'test\n\n```\n foo \n```\n\nbar' diff --git a/tests/test_custom_converter.py b/tests/test_custom_converter.py index a3e33ac..f4734c9 100644 --- a/tests/test_custom_converter.py +++ b/tests/test_custom_converter.py @@ -2,21 +2,28 @@ from bs4 import BeautifulSoup -class ImageBlockConverter(MarkdownConverter): +class UnitTestConverter(MarkdownConverter): """ - Create a custom MarkdownConverter that adds two newlines after an image + Create a custom MarkdownConverter for unit tests """ - def convert_img(self, el, text, convert_as_inline): - return super().convert_img(el, text, convert_as_inline) + '\n\n' + def convert_img(self, el, text, parent_tags): + """Add two newlines after an image""" + return super().convert_img(el, text, parent_tags) + '\n\n' + def convert_custom_tag(self, el, text, parent_tags): + """Ensure conversion function is found for tags with special characters in name""" + return "FUNCTION USED: %s" % text -def test_img(): + +def test_custom_conversion_functions(): # Create shorthand method for conversion def md(html, **options): - return ImageBlockConverter(**options).convert(html) + return UnitTestConverter(**options).convert(html) + + assert md('Alt texttext') == '![Alt text](/path/to/img.jpg "Optional title")\n\ntext' + assert md('Alt texttext') == '![Alt text](/path/to/img.jpg)\n\ntext' - assert md('Alt text') == '![Alt text](/path/to/img.jpg "Optional title")\n\n' - assert md('Alt text') == '![Alt text](/path/to/img.jpg)\n\n' + assert md("text") == "FUNCTION USED: text" def test_soup(): diff --git a/tests/test_escaping.py b/tests/test_escaping.py index 878760a..bab4d11 100644 --- a/tests/test_escaping.py +++ b/tests/test_escaping.py @@ -1,6 +1,6 @@ import warnings from bs4 import MarkupResemblesLocatorWarning -from markdownify import markdownify as md +from .utils import md def test_asterisks(): @@ -51,7 +51,9 @@ def test_misc(): assert md('-y', escape_misc=True) == '-y' assert md('+ x\n+ y\n', escape_misc=True) == '\\+ x\n\\+ y\n' assert md('`x`', escape_misc=True) == r'\`x\`' - assert md('[text](link)', escape_misc=True) == r'\[text](link)' + assert md('[text](notalink)', escape_misc=True) == r'\[text\](notalink)' + assert md('text]', escape_misc=True) == r'[text\]](link)' + assert md('[text]', escape_misc=True) == r'[\[text\]](link)' assert md('1. x', escape_misc=True) == r'1\. x' # assert md('1. x', escape_misc=True) == r'1\. x' assert md('1. x', escape_misc=True) == r'1\. x' diff --git a/tests/test_lists.py b/tests/test_lists.py index a660778..e9480ab 100644 --- a/tests/test_lists.py +++ b/tests/test_lists.py @@ -1,4 +1,4 @@ -from markdownify import markdownify as md +from .utils import md nested_uls = """ @@ -42,12 +42,13 @@ def test_ol(): assert md('
    1. a
    2. b
    ') == '\n\n1. a\n2. b\n' + assert md('
    1. a
    2. b
    ') == '\n\n1. a\n2. b\n' assert md('
    1. a
    2. b
    ') == '\n\n3. a\n4. b\n' assert md('foo
    1. a
    2. b
    bar') == 'foo\n\n3. a\n4. b\n\nbar' assert md('
    1. a
    2. b
    ') == '\n\n1. a\n2. b\n' assert md('
    1. a
    2. b
    ') == '\n\n1. a\n2. b\n' assert md('
    1. a
    2. b
    ') == '\n\n1. a\n2. b\n' - assert md('
    1. first para

      second para

    2. third para

      fourth para

    ') == '\n\n1234. first para\n \n second para\n1235. third para\n \n fourth para\n' + assert md('
    1. first para

      second para

    2. third para

      fourth para

    ') == '\n\n1234. first para\n\n second para\n1235. third para\n\n fourth para\n' def test_nested_ols(): @@ -64,7 +65,7 @@ def test_ul():
  • c
""") == '\n\n* a\n* b\n* c\n' - assert md('
  • first para

    second para

  • third para

    fourth para

') == '\n\n* first para\n \n second para\n* third para\n \n fourth para\n' + assert md('
  • first para

    second para

  • third para

    fourth para

') == '\n\n* first para\n\n second para\n* third para\n\n fourth para\n' def test_inline_ul(): diff --git a/tests/test_tables.py b/tests/test_tables.py index fc6eee6..e41b389 100644 --- a/tests/test_tables.py +++ b/tests/test_tables.py @@ -1,4 +1,4 @@ -from markdownify import markdownify as md +from .utils import md table = """ @@ -141,6 +141,33 @@
""" +table_head_body_multiple_head = """ + + + + + + + + + + + + + + + + + + + + + + + + +
CreatorEditorServer
OperatorManagerEngineer
BobOliverTom
ThomasLucasEthan
""" + table_missing_text = """ @@ -201,7 +228,10 @@
""" -table_with_caption = """TEXT +table_with_caption = """TEXT
Caption
+ @@ -245,10 +275,28 @@ def test_table(): assert md(table_with_linebreaks) == '\n\n| Firstname | Lastname | Age |\n| --- | --- | --- |\n| Jill | Smith Jackson | 50 |\n| Eve | Jackson Smith | 94 |\n\n' assert md(table_with_header_column) == '\n\n| Firstname | Lastname | Age |\n| --- | --- | --- |\n| Jill | Smith | 50 |\n| Eve | Jackson | 94 |\n\n' assert md(table_head_body) == '\n\n| Firstname | Lastname | Age |\n| --- | --- | --- |\n| Jill | Smith | 50 |\n| Eve | Jackson | 94 |\n\n' + assert md(table_head_body_multiple_head) == '\n\n| | | |\n| --- | --- | --- |\n| Creator | Editor | Server |\n| Operator | Manager | Engineer |\n| Bob | Oliver | Tom |\n| Thomas | Lucas | Ethan |\n\n' assert md(table_head_body_missing_head) == '\n\n| Firstname | Lastname | Age |\n| --- | --- | --- |\n| Jill | Smith | 50 |\n| Eve | Jackson | 94 |\n\n' assert md(table_missing_text) == '\n\n| | Lastname | Age |\n| --- | --- | --- |\n| Jill | | 50 |\n| Eve | Jackson | 94 |\n\n' - assert md(table_missing_head) == '\n\n| Firstname | Lastname | Age |\n| --- | --- | --- |\n| Jill | Smith | 50 |\n| Eve | Jackson | 94 |\n\n' - assert md(table_body) == '\n\n| Firstname | Lastname | Age |\n| --- | --- | --- |\n| Jill | Smith | 50 |\n| Eve | Jackson | 94 |\n\n' - assert md(table_with_caption) == 'TEXT\n\nCaption\n| Firstname | Lastname | Age |\n| --- | --- | --- |\n\n' + assert md(table_missing_head) == '\n\n| | | |\n| --- | --- | --- |\n| Firstname | Lastname | Age |\n| Jill | Smith | 50 |\n| Eve | Jackson | 94 |\n\n' + assert md(table_body) == '\n\n| | | |\n| --- | --- | --- |\n| Firstname | Lastname | Age |\n| Jill | Smith | 50 |\n| Eve | Jackson | 94 |\n\n' + assert md(table_with_caption) == 'TEXT\n\nCaption\n\n| | | |\n| --- | --- | --- |\n| Firstname | Lastname | Age |\n\n' assert md(table_with_colspan) == '\n\n| Name | | Age |\n| --- | --- | --- |\n| Jill | Smith | 50 |\n| Eve | Jackson | 94 |\n\n' assert md(table_with_undefined_colspan) == '\n\n| Name | Age |\n| --- | --- |\n| Jill | Smith |\n\n' + + +def test_table_infer_header(): + assert md(table, table_infer_header=True) == '\n\n| Firstname | Lastname | Age |\n| --- | --- | --- |\n| Jill | Smith | 50 |\n| Eve | Jackson | 94 |\n\n' + assert md(table_with_html_content, table_infer_header=True) == '\n\n| Firstname | Lastname | Age |\n| --- | --- | --- |\n| **Jill** | *Smith* | [50](#) |\n| Eve | Jackson | 94 |\n\n' + assert md(table_with_paragraphs, table_infer_header=True) == '\n\n| Firstname | Lastname | Age |\n| --- | --- | --- |\n| Jill | Smith | 50 |\n| Eve | Jackson | 94 |\n\n' + assert md(table_with_linebreaks, table_infer_header=True) == '\n\n| Firstname | Lastname | Age |\n| --- | --- | --- |\n| Jill | Smith Jackson | 50 |\n| Eve | Jackson Smith | 94 |\n\n' + assert md(table_with_header_column, table_infer_header=True) == '\n\n| Firstname | Lastname | Age |\n| --- | --- | --- |\n| Jill | Smith | 50 |\n| Eve | Jackson | 94 |\n\n' + assert md(table_head_body, table_infer_header=True) == '\n\n| Firstname | Lastname | Age |\n| --- | --- | --- |\n| Jill | Smith | 50 |\n| Eve | Jackson | 94 |\n\n' + assert md(table_head_body_multiple_head, table_infer_header=True) == '\n\n| Creator | Editor | Server |\n| --- | --- | --- |\n| Operator | Manager | Engineer |\n| Bob | Oliver | Tom |\n| Thomas | Lucas | Ethan |\n\n' + assert md(table_head_body_missing_head, table_infer_header=True) == '\n\n| Firstname | Lastname | Age |\n| --- | --- | --- |\n| Jill | Smith | 50 |\n| Eve | Jackson | 94 |\n\n' + assert md(table_missing_text, table_infer_header=True) == '\n\n| | Lastname | Age |\n| --- | --- | --- |\n| Jill | | 50 |\n| Eve | Jackson | 94 |\n\n' + assert md(table_missing_head, table_infer_header=True) == '\n\n| Firstname | Lastname | Age |\n| --- | --- | --- |\n| Jill | Smith | 50 |\n| Eve | Jackson | 94 |\n\n' + assert md(table_body, table_infer_header=True) == '\n\n| Firstname | Lastname | Age |\n| --- | --- | --- |\n| Jill | Smith | 50 |\n| Eve | Jackson | 94 |\n\n' + assert md(table_with_caption, table_infer_header=True) == 'TEXT\n\nCaption\n\n| Firstname | Lastname | Age |\n| --- | --- | --- |\n\n' + assert md(table_with_colspan, table_infer_header=True) == '\n\n| Name | | Age |\n| --- | --- | --- |\n| Jill | Smith | 50 |\n| Eve | Jackson | 94 |\n\n' + assert md(table_with_undefined_colspan, table_infer_header=True) == '\n\n| Name | Age |\n| --- | --- |\n| Jill | Smith |\n\n' diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..0dac580 --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,9 @@ +from markdownify import MarkdownConverter + + +# for unit testing, disable document-level stripping by default so that +# separation newlines are included in testing +def md(html, **options): + options = {"strip_document": None, **options} + + return MarkdownConverter(**options).convert(html)
+ Caption +
Firstname Lastname Age