Repository: astanin/python-tabulate
Pull request #117 (2021-03-03 22:21:18)

Enable automatic word wrap based on desired maximum column width
This address as submitted issue #90 that allows automatically word wrapping long text string. From an API standpoint, this is automatically enabled by simply specifying the maximum column width for a given column. I selected max column width instead of just column width to remove any confusion where a user might think that specifying `colwidth` might be setting a guaranteed size, which is not the behavior here (though I could see it as a possible future update?)

The "magic" here is a customized version of the `textwrap.TextWrapper` class to not use just `len` for strings and to also preserve ANSI color codes around wrapped lines. This is a non-trivial bit of code (even though heavily copy/pasted) with possible regression concerns, so I put it into a new test file to help tack this closely. Hopefully this fits within this projects idioms. 

To address the 'tests that need to be covered' from the issue discussion:

* colwidths parameter can be specified as a number (the same for all column)
  * Done
* colwidths parameter can be shorter than the number of column (don't wrap text in the remaining columns)
  * Done
* the feature works when all columns are text
  * Done
* the feature works when some columns are numeric
  * Done - I think. I think it behaves as sensibly as it can (ignores numbers), and it also works with `disable_numparse`. Let me know if you had something else in mind, 
* the feature works when some columns contain wide characters (Japanese/Chinese/Korean)
  * Done
* the feature works when some columns contain invisible control characters (ASCII escape codes, color, hyperlinks)
  * Done
* the feature works reasonably well or is disabled in formats which cannot support it (HTML? LaTeX?)
  * This has to be explicitly enabled by setting `maxcolwidth`, so I believe this meets the ability to ensure it does not impact these formats
* when bidirectional script support is implemented first, this feature should work correctly when there's mixed left-to-right and right-to-left text in a cell (English + Arabic/Hebrew/...)
  * N/A


Here's a small sample program I used to quickly demonstrate all the big capability updates

```
from tabulate import tabulate
all_data = [
    [('1', 'John Smith', '\033[31mThis is a rather long description that might look better if it is wrapped a bit\033[0m')],
    [('1', 'John Smith', '\033[31m약간 감싸면 더 잘 보일 수있는 다소 긴 설명입니다 설명입니다 설명입니다 설명입니다 설명\033[0m')],
    [('1', 'John Smith', '\033[31m\033[43mThis is a rather long description that might look better if it is wrapped a bit\033[0m')],
    [('1', 'John Smith', 'This is a rather \033[31mlong description\033[0m that might look better \033[93mif it is wrapped\033[0m a bit')]
]
for data in all_data:
    headers=("Issue Id", "Author", "Description")
    print(tabulate(data,  headers, tablefmt="grid", maxcolwidths=[None, None, 30]))
```


Patch:
diff --git a/README.md b/README.md
index 9ccb100..5c4b62a 100644
--- a/README.md
+++ b/README.md
@@ -638,6 +638,33 @@ a multiline cell, and headers with a multiline cell:
 
 Multiline cells are not well supported for the other table formats.
 
+### Automating Multilines
+While tabulate supports data passed in with multiines entries explicitly provided,
+it also provides some support to help manage this work internally.
+
+The `maxcolwidths` argument is a list where each entry specifies the max width for
+it's respective column. Any cell that will exceed this will automatically wrap the content.
+To assign the same max width for all columns, a singular int scaler can be used.
+
+Use `None` for any columns where an explicit maximum does not need to be provided,
+and thus no automate multiline wrapping will take place.
+
+The wraping uses the python standard [textwrap.wrap](https://docs.python.org/3/library/textwrap.html#textwrap.wrap)
+function with default parameters - aside from width.
+
+This example demonstrates usagage of automatic multiline wrapping, though typically
+the lines being wrapped would probably be significantly longer than this.
+
+    >>> print(tabulate([["John Smith", "Middle Manager"]], headers=["Name", "Title"], tablefmt="grid", maxcolwidths=[None, 8]))
+    +------------+---------+
+    | Name       | Title   |
+    +============+=========+
+    | John Smith | Middle  |
+    |            | Manager |
+    +------------+---------+
+
+
+
 Usage of the command line utility
 ---------------------------------
 
diff --git a/tabulate.py b/tabulate.py
index 55b896e..0579fb3 100644
--- a/tabulate.py
+++ b/tabulate.py
@@ -8,6 +8,7 @@ from collections import namedtuple
 import sys
 import re
 import math
+import textwrap
 
 
 if sys.version_info >= (3, 3):
@@ -569,6 +570,8 @@ _invisible_codes_link = re.compile(
     r"\x1B]8;[a-zA-Z0-9:]*;[^\x1B]+\x1B\\([^\x1b]+)\x1B]8;;\x1B\\"
 )  # Terminal hyperlinks
 
+_ansi_color_reset_code = "\033[0m"
+
 
 def simple_separated_format(separator):
     """Construct a simple TableFormat with columns separated by a separator.
@@ -1213,6 +1216,29 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"):
     return rows, headers
 
 
+def _wrap_text_to_colwidths(list_of_lists, colwidths, numparses=True):
+    numparses = _expand_iterable(numparses, len(list_of_lists[0]), True)
+
+    result = []
+
+    for row in list_of_lists:
+        new_row = []
+        for cell, width, numparse in zip(row, colwidths, numparses):
+            if _isnumber(cell) and numparse:
+                new_row.append(cell)
+                continue
+
+            if width is not None:
+                wrapper = _CustomTextWrap(width=width)
+                wrapped = wrapper.wrap(cell)
+                new_row.append("\n".join(wrapped))
+            else:
+                new_row.append(cell)
+        result.append(new_row)
+
+    return result
+
+
 def tabulate(
     tabular_data,
     headers=(),
@@ -1224,6 +1250,7 @@ def tabulate(
     showindex="default",
     disable_numparse=False,
     colalign=None,
+    maxcolwidths=None,
 ):
     """Format a fixed width table for pretty printing.
 
@@ -1521,6 +1548,31 @@ def tabulate(
     indices is used to disable number parsing only on those columns
     e.g. `disable_numparse=[0, 2]` would disable number parsing only on the
     first and third columns.
+
+    Column Widths and Auto Line Wrapping
+    ------------------------------------
+    Tabulate will, by default, set the width of each column to the length of the
+    longest element in that column. However, in situations where fields are expected
+    to reasonably be too long to look good as a single line, tabulate can help automate
+    word wrapping long fields for you. Use the parameter `maxcolwidth` to provide a
+    list of maximal column widths
+
+    >>> print(tabulate( \
+          [('1', 'John Smith', \
+            'This is a rather long description that might look better if it is wrapped a bit')], \
+          headers=("Issue Id", "Author", "Description"), \
+          maxcolwidths=[None, None, 30], \
+          tablefmt="grid"  \
+        ))
+    +------------+------------+-------------------------------+
+    |   Issue Id | Author     | Description                   |
+    +============+============+===============================+
+    |          1 | John Smith | This is a rather long         |
+    |            |            | description that might look   |
+    |            |            | better if it is wrapped a bit |
+    +------------+------------+-------------------------------+
+
+
     """
 
     if tabular_data is None:
@@ -1529,6 +1581,18 @@ def tabulate(
         tabular_data, headers, showindex=showindex
     )
 
+    if maxcolwidths is not None:
+        num_cols = len(list_of_lists[0])
+        if isinstance(maxcolwidths, int):  # Expand scalar for all columns
+            maxcolwidths = _expand_iterable(maxcolwidths, num_cols, maxcolwidths)
+        else:  # Ignore col width for any 'trailing' columns
+            maxcolwidths = _expand_iterable(maxcolwidths, num_cols, None)
+
+        numparses = _expand_numparse(disable_numparse, num_cols)
+        list_of_lists = _wrap_text_to_colwidths(
+            list_of_lists, maxcolwidths, numparses=numparses
+        )
+
     # empty values in the first column of RST tables should be escaped (issue #82)
     # "" should be escaped as "\\ " or ".."
     if tablefmt == "rst":
@@ -1647,6 +1711,20 @@ def _expand_numparse(disable_numparse, column_count):
         return [not disable_numparse] * column_count
 
 
+def _expand_iterable(original, num_desired, default):
+    """
+    Expands the `original` argument to return a return a list of
+    length `num_desired`. If `original` is shorter than `num_desired`, it will
+    be padded with the value in `default`.
+    If `original` is not a list to begin with (i.e. scalar value) a list of
+    length `num_desired` completely populated with `default will be returned
+    """
+    if isinstance(original, Iterable):
+        return original + [default] * (num_desired - len(original))
+    else:
+        return [default] * num_desired
+
+
 def _pad_row(cells, padding):
     if cells:
         pad = " " * padding
@@ -1774,6 +1852,206 @@ def _format_table(fmt, headers, rows, colwidths, colaligns, is_multiline):
         return ""
 
 
+class _CustomTextWrap(textwrap.TextWrapper):
+    """A custom implementation of CPython's textwrap.TextWrapper. This supports
+    both wide characters (Korea, Japanese, Chinese)  - including mixed string.
+    For the most part, the `_handle_long_word` and `_wrap_chunks` functions were
+    copy pasted out of the CPython baseline, and updated with our custom length
+    and line appending logic.
+    """
+
+    def __init__(self, *args, **kwargs):
+        self._active_codes = []
+        self.max_lines = None  # For python2 compatibility
+        textwrap.TextWrapper.__init__(self, *args, **kwargs)
+
+    @staticmethod
+    def _len(item):
+        """Custom len that gets console column width for wide
+        and non-wide characters as well as ignores color codes"""
+        stripped = _strip_invisible(item)
+        if wcwidth:
+            return wcwidth.wcswidth(stripped)
+        else:
+            return len(stripped)
+
+    def _update_lines(self, lines, new_line):
+        """Adds a new line to the list of lines the text is being wrapped into
+        This function will also track any ANSI color codes in this string as well
+        as add any colors from previous lines order to preserve the same formatting
+        as a single unwrapped string.
+        """
+        code_matches = [x for x in re.finditer(_invisible_codes, new_line)]
+        color_codes = [
+            code.string[code.span()[0] : code.span()[1]] for code in code_matches
+        ]
+
+        # Add color codes from earlier in the unwrapped line, and then track any new ones we add.
+        new_line = "".join(self._active_codes) + new_line
+
+        for code in color_codes:
+            if code != _ansi_color_reset_code:
+                self._active_codes.append(code)
+            else:  # A single reset code resets everything
+                self._active_codes = []
+
+        # Always ensure each line is color terminted if any colors are
+        # still active, otherwise colors will bleed into other cells on the console
+        if len(self._active_codes) > 0:
+            new_line = new_line + _ansi_color_reset_code
+
+        lines.append(new_line)
+
+    def _handle_long_word(self, reversed_chunks, cur_line, cur_len, width):
+        """_handle_long_word(chunks : [string],
+                             cur_line : [string],
+                             cur_len : int, width : int)
+        Handle a chunk of text (most likely a word, not whitespace) that
+        is too long to fit in any line.
+        """
+        # Figure out when indent is larger than the specified width, and make
+        # sure at least one character is stripped off on every pass
+        if width < 1:
+            space_left = 1
+        else:
+            space_left = width - cur_len
+
+        # If we're allowed to break long words, then do so: put as much
+        # of the next chunk onto the current line as will fit.
+        if self.break_long_words:
+            # Tabulate Custom: Build the string up piece-by-piece in order to
+            # take each charcter's width into account
+            chunk = reversed_chunks[-1]
+            i = 1
+            while self._len(chunk[:i]) <= space_left:
+                i = i + 1
+            cur_line.append(chunk[: i - 1])
+            reversed_chunks[-1] = chunk[i - 1 :]
+
+        # Otherwise, we have to preserve the long word intact.  Only add
+        # it to the current line if there's nothing already there --
+        # that minimizes how much we violate the width constraint.
+        elif not cur_line:
+            cur_line.append(reversed_chunks.pop())
+
+        # If we're not allowed to break long words, and there's already
+        # text on the current line, do nothing.  Next time through the
+        # main loop of _wrap_chunks(), we'll wind up here again, but
+        # cur_len will be zero, so the next line will be entirely
+        # devoted to the long word that we can't handle right now.
+
+    def _wrap_chunks(self, chunks):
+        """_wrap_chunks(chunks : [string]) -> [string]
+        Wrap a sequence of text chunks and return a list of lines of
+        length 'self.width' or less.  (If 'break_long_words' is false,
+        some lines may be longer than this.)  Chunks correspond roughly
+        to words and the whitespace between them: each chunk is
+        indivisible (modulo 'break_long_words'), but a line break can
+        come between any two chunks.  Chunks should not have internal
+        whitespace; ie. a chunk is either all whitespace or a "word".
+        Whitespace chunks will be removed from the beginning and end of
+        lines, but apart from that whitespace is preserved.
+        """
+        lines = []
+        if self.width <= 0:
+            raise ValueError("invalid width %r (must be > 0)" % self.width)
+        if self.max_lines is not None:
+            if self.max_lines > 1:
+                indent = self.subsequent_indent
+            else:
+                indent = self.initial_indent
+            if self._len(indent) + self._len(self.placeholder.lstrip()) > self.width:
+                raise ValueError("placeholder too large for max width")
+
+        # Arrange in reverse order so items can be efficiently popped
+        # from a stack of chucks.
+        chunks.reverse()
+
+        while chunks:
+
+            # Start the list of chunks that will make up the current line.
+            # cur_len is just the length of all the chunks in cur_line.
+            cur_line = []
+            cur_len = 0
+
+            # Figure out which static string will prefix this line.
+            if lines:
+                indent = self.subsequent_indent
+            else:
+                indent = self.initial_indent
+
+            # Maximum width for this line.
+            width = self.width - self._len(indent)
+
+            # First chunk on line is whitespace -- drop it, unless this
+            # is the very beginning of the text (ie. no lines started yet).
+            if self.drop_whitespace and chunks[-1].strip() == "" and lines:
+                del chunks[-1]
+
+            while chunks:
+                chunk_len = self._len(chunks[-1])
+
+                # Can at least squeeze this chunk onto the current line.
+                if cur_len + chunk_len <= width:
+                    cur_line.append(chunks.pop())
+                    cur_len += chunk_len
+
+                # Nope, this line is full.
+                else:
+                    break
+
+            # The current line is full, and the next chunk is too big to
+            # fit on *any* line (not just this one).
+            if chunks and self._len(chunks[-1]) > width:
+                self._handle_long_word(chunks, cur_line, cur_len, width)
+                cur_len = sum(map(self._len, cur_line))
+
+            # If the last chunk on this line is all whitespace, drop it.
+            if self.drop_whitespace and cur_line and cur_line[-1].strip() == "":
+                cur_len -= self._len(cur_line[-1])
+                del cur_line[-1]
+
+            if cur_line:
+                if (
+                    self.max_lines is None
+                    or len(lines) + 1 < self.max_lines
+                    or (
+                        not chunks
+                        or self.drop_whitespace
+                        and len(chunks) == 1
+                        and not chunks[0].strip()
+                    )
+                    and cur_len <= width
+                ):
+                    # Convert current line back to a string and store it in
+                    # list of all lines (return value).
+                    self._update_lines(lines, indent + "".join(cur_line))
+                else:
+                    while cur_line:
+                        if (
+                            cur_line[-1].strip()
+                            and cur_len + self._len(self.placeholder) <= width
+                        ):
+                            cur_line.append(self.placeholder)
+                            self._update_lines(lines, indent + "".join(cur_line))
+                            break
+                        cur_len -= self._len(cur_line[-1])
+                        del cur_line[-1]
+                    else:
+                        if lines:
+                            prev_line = lines[-1].rstrip()
+                            if (
+                                self._len(prev_line) + self._len(self.placeholder)
+                                <= self.width
+                            ):
+                                lines[-1] = prev_line + self.placeholder
+                                break
+                        self._update_lines(lines, indent + self.placeholder.lstrip())
+                    break
+
+        return lines
+
+
 def _main():
     """\
     Usage: tabulate [options] [FILE ...]


Test patch:
diff --git a/test/test_api.py b/test/test_api.py
index bf56e82..5b77c0e 100644
--- a/test/test_api.py
+++ b/test/test_api.py
@@ -34,6 +34,9 @@ def _check_signature(function, expected_sig):
         skip("")
     actual_sig = signature(function)
     print("expected: %s\nactual: %s\n" % (expected_sig, str(actual_sig)))
+
+    assert len(actual_sig.parameters) == len(expected_sig)
+
     for (e, ev), (a, av) in zip(expected_sig, actual_sig.parameters.items()):
         assert e == a and ev == av.default
 
@@ -49,6 +52,10 @@ def test_tabulate_signature():
         ("numalign", "default"),
         ("stralign", "default"),
         ("missingval", ""),
+        ("showindex", "default"),
+        ("disable_numparse", False),
+        ("colalign", None),
+        ("maxcolwidths", None),
     ]
     _check_signature(tabulate, expected_sig)
 
diff --git a/test/test_internal.py b/test/test_internal.py
index 035558f..44765cf 100644
--- a/test/test_internal.py
+++ b/test/test_internal.py
@@ -5,7 +5,7 @@
 from __future__ import print_function
 from __future__ import unicode_literals
 import tabulate as T
-from common import assert_equal
+from common import assert_equal, skip
 
 
 def test_multiline_width():
@@ -45,3 +45,167 @@ def test_align_column_multiline():
     output = T._align_column(column, "center", is_multiline=True)
     expected = ["  1  ", " 123 ", "12345" + "\n" + "  6  "]
     assert_equal(output, expected)
+
+
+def test_wrap_text_to_colwidths():
+    "Internal: Test _wrap_text_to_colwidths to show it will wrap text based on colwidths"
+    rows = [
+        ["mini", "medium", "decently long", "wrap will be ignored"],
+        [
+            "small",
+            "JustOneWordThatIsWayTooLong",
+            "this is unreasonably long for a single cell length",
+            "also ignored here",
+        ],
+    ]
+    widths = [10, 10, 20, None]
+    expected = [
+        ["mini", "medium", "decently long", "wrap will be ignored"],
+        [
+            "small",
+            "JustOneWor\ndThatIsWay\nTooLong",
+            "this is unreasonably\nlong for a single\ncell length",
+            "also ignored here",
+        ],
+    ]
+    result = T._wrap_text_to_colwidths(rows, widths)
+
+    assert_equal(result, expected)
+
+
+def test_wrap_text_wide_chars():
+    "Internal: Wrap wide characters based on column width"
+    try:
+        import wcwidth  # noqa
+    except ImportError:
+        skip("test_wrap_text_wide_chars is skipped")
+
+    rows = [["청자청자청자청자청자", "약간 감싸면 더 잘 보일 수있는 다소 긴 설명입니다"]]
+    widths = [5, 20]
+    expected = [["청자\n청자\n청자\n청자\n청자", "약간 감싸면 더 잘\n보일 수있는 다소 긴\n설명입니다"]]
+    result = T._wrap_text_to_colwidths(rows, widths)
+
+    assert_equal(result, expected)
+
+
+def test_wrap_text_to_numbers():
+    """Internal: Test _wrap_text_to_colwidths force ignores numbers by
+    default so as not to break alignment behaviors"""
+    rows = [
+        ["first number", 123.456789, "123.456789"],
+        ["second number", "987654.123", "987654.123"],
+    ]
+    widths = [6, 6, 6]
+    expected = [
+        ["first\nnumber", 123.456789, "123.45\n6789"],
+        ["second\nnumber", "987654.123", "987654\n.123"],
+    ]
+
+    result = T._wrap_text_to_colwidths(rows, widths, numparses=[True, True, False])
+    assert_equal(result, expected)
+
+
+def test_wrap_text_to_colwidths_single_ansi_colors_full_cell():
+    """Internal: autowrapped text can retain a single ANSI colors
+    when it is at the beginning and end of full cell"""
+    data = [
+        [
+            (
+                "\033[31mThis is a rather long description that might"
+                " look better if it is wrapped a bit\033[0m"
+            )
+        ]
+    ]
+    result = T._wrap_text_to_colwidths(data, [30])
+
+    expected = [
+        [
+            "\n".join(
+                [
+                    "\033[31mThis is a rather long\033[0m",
+                    "\033[31mdescription that might look\033[0m",
+                    "\033[31mbetter if it is wrapped a bit\033[0m",
+                ]
+            )
+        ]
+    ]
+    assert_equal(expected, result)
+
+
+def test_wrap_text_to_colwidths_colors_wide_char():
+    """Internal: autowrapped text can retain a ANSI colors with wide chars"""
+    try:
+        import wcwidth  # noqa
+    except ImportError:
+        skip("test_wrap_text_to_colwidths_colors_wide_char is skipped")
+
+    data = [[("\033[31m약간 감싸면 더 잘 보일 수있는 다소 긴" " 설명입니다 설명입니다 설명입니다 설명입니다 설명\033[0m")]]
+    result = T._wrap_text_to_colwidths(data, [30])
+
+    expected = [
+        [
+            "\n".join(
+                [
+                    "\033[31m약간 감싸면 더 잘 보일 수있는\033[0m",
+                    "\033[31m다소 긴 설명입니다 설명입니다\033[0m",
+                    "\033[31m설명입니다 설명입니다 설명\033[0m",
+                ]
+            )
+        ]
+    ]
+    assert_equal(expected, result)
+
+
+def test_wrap_text_to_colwidths_multi_ansi_colors_full_cell():
+    """Internal: autowrapped text can retain multiple ANSI colors
+    when they are at the beginning and end of full cell
+    (e.g. text and background colors)"""
+    data = [
+        [
+            (
+                "\033[31m\033[43mThis is a rather long description that"
+                " might look better if it is wrapped a bit\033[0m"
+            )
+        ]
+    ]
+    result = T._wrap_text_to_colwidths(data, [30])
+
+    expected = [
+        [
+            "\n".join(
+                [
+                    "\033[31m\033[43mThis is a rather long\033[0m",
+                    "\033[31m\033[43mdescription that might look\033[0m",
+                    "\033[31m\033[43mbetter if it is wrapped a bit\033[0m",
+                ]
+            )
+        ]
+    ]
+    assert_equal(expected, result)
+
+
+def test_wrap_text_to_colwidths_multi_ansi_colors_in_subset():
+    """Internal: autowrapped text can retain multiple ANSI colors
+    when they are around subsets of the cell"""
+    data = [
+        [
+            (
+                "This is a rather \033[31mlong description\033[0m that"
+                " might look better \033[93mif it is wrapped\033[0m a bit"
+            )
+        ]
+    ]
+    result = T._wrap_text_to_colwidths(data, [30])
+
+    expected = [
+        [
+            "\n".join(
+                [
+                    "This is a rather \033[31mlong\033[0m",
+                    "\033[31mdescription\033[0m that might look",
+                    "better \033[93mif it is wrapped\033[0m a bit",
+                ]
+            )
+        ]
+    ]
+    assert_equal(expected, result)
diff --git a/test/test_output.py b/test/test_output.py
index abab439..eed4489 100644
--- a/test/test_output.py
+++ b/test/test_output.py
@@ -104,6 +104,103 @@ def test_plain_multiline_with_empty_cells_headerless():
     assert_equal(expected, result)
 
 
+def test_plain_maxcolwidth_autowraps():
+    "Output: maxcolwidth will result in autowrapping longer cells"
+    table = [["hdr", "fold"], ["1", "very long data"]]
+    expected = "\n".join(["  hdr  fold", "    1  very long", "       data"])
+    result = tabulate(
+        table, headers="firstrow", tablefmt="plain", maxcolwidths=[10, 10]
+    )
+    assert_equal(expected, result)
+
+
+def test_plain_maxcolwidth_autowraps_wide_chars():
+    "Output: maxcolwidth and autowrapping functions with wide characters"
+    try:
+        import wcwidth  # noqa
+    except ImportError:
+        skip("test_wrap_text_wide_chars is skipped")
+
+    table = [
+        ["hdr", "fold"],
+        ["1", "약간 감싸면 더 잘 보일 수있는 다소 긴 설명입니다 설명입니다 설명입니다 설명입니다 설명"],
+    ]
+    expected = "\n".join(
+        [
+            "  hdr  fold",
+            "    1  약간 감싸면 더 잘 보일 수있는",
+            "       다소 긴 설명입니다 설명입니다",
+            "       설명입니다 설명입니다 설명",
+        ]
+    )
+    result = tabulate(
+        table, headers="firstrow", tablefmt="plain", maxcolwidths=[10, 30]
+    )
+    assert_equal(expected, result)
+
+
+def test_maxcolwidth_single_value():
+    "Output: maxcolwidth can be specified as a single number that works for each column"
+    table = [
+        ["hdr", "fold1", "fold2"],
+        ["mini", "this is short", "this is a bit longer"],
+    ]
+    expected = "\n".join(
+        [
+            "hdr    fold1    fold2",
+            "mini   this     this",
+            "       is       is a",
+            "       short    bit",
+            "                longer",
+        ]
+    )
+    result = tabulate(table, headers="firstrow", tablefmt="plain", maxcolwidths=6)
+    assert_equal(expected, result)
+
+
+def test_maxcolwidth_pad_tailing_widths():
+    "Output: maxcolwidth, if only partly specified, pads tailing cols with None"
+    table = [
+        ["hdr", "fold1", "fold2"],
+        ["mini", "this is short", "this is a bit longer"],
+    ]
+    expected = "\n".join(
+        [
+            "hdr    fold1    fold2",
+            "mini   this     this is a bit longer",
+            "       is",
+            "       short",
+        ]
+    )
+    result = tabulate(
+        table, headers="firstrow", tablefmt="plain", maxcolwidths=[None, 6]
+    )
+    assert_equal(expected, result)
+
+
+def test_maxcolwidth_honor_disable_parsenum():
+    "Output: Using maxcolwidth in conjunction with disable_parsenum is honored"
+    table = [
+        ["first number", 123.456789, "123.456789"],
+        ["second number", "987654321.123", "987654321.123"],
+    ]
+    expected = "\n".join(
+        [
+            "+--------+---------------+--------+",
+            "| first  | 123.457       | 123.45 |",
+            "| number |               | 6789   |",
+            "+--------+---------------+--------+",
+            "| second |   9.87654e+08 | 987654 |",
+            "| number |               | 321.12 |",
+            "|        |               | 3      |",
+            "+--------+---------------+--------+",
+        ]
+    )
+    # Grid makes showing the alignment difference a little easier
+    result = tabulate(table, tablefmt="grid", maxcolwidths=6, disable_numparse=[2])
+    assert_equal(expected, result)
+
+
 def test_simple():
     "Output: simple with headers"
     expected = "\n".join(
diff --git a/test/test_textwrapper.py b/test/test_textwrapper.py
new file mode 100644
index 0000000..e8e1c09
--- /dev/null
+++ b/test/test_textwrapper.py
@@ -0,0 +1,158 @@
+# -*- coding: utf-8 -*-
+
+"""Discretely test functionality of our custom TextWrapper"""
+from __future__ import unicode_literals
+
+from tabulate import _CustomTextWrap as CTW
+from textwrap import TextWrapper as OTW
+
+from common import skip, assert_equal
+
+
+def test_wrap_multiword_non_wide():
+    """TextWrapper: non-wide character regression tests"""
+    data = "this is a test string for regression spiltting"
+    for width in range(1, len(data)):
+        orig = OTW(width=width)
+        cust = CTW(width=width)
+
+        assert orig.wrap(data) == cust.wrap(
+            data
+        ), "Failure on non-wide char multiword regression check for width " + str(width)
+
+
+def test_wrap_multiword_non_wide_with_hypens():
+    """TextWrapper: non-wide character regression tests that contain hypens"""
+    data = "how should-we-split-this non-sense string that-has-lots-of-hypens"
+    for width in range(1, len(data)):
+        orig = OTW(width=width)
+        cust = CTW(width=width)
+
+        assert orig.wrap(data) == cust.wrap(
+            data
+        ), "Failure on non-wide char hyphen regression check for width " + str(width)
+
+
+def test_wrap_longword_non_wide():
+    """TextWrapper: Some non-wide character regression tests"""
+    data = "ThisIsASingleReallyLongWordThatWeNeedToSplit"
+    for width in range(1, len(data)):
+        orig = OTW(width=width)
+        cust = CTW(width=width)
+
+        assert orig.wrap(data) == cust.wrap(
+            data
+        ), "Failure on non-wide char longword regression check for width " + str(width)
+
+
+def test_wrap_wide_char_multiword():
+    """TextWrapper: wrapping support for wide characters with mulitple words"""
+    try:
+        import wcwidth  # noqa
+    except ImportError:
+        skip("test_wrap_wide_char is skipped")
+
+    data = "약간 감싸면 더 잘 보일 수있는 다소 긴 설명입니다"
+
+    expected = ["약간 감싸면 더", "잘 보일 수있는", "다소 긴", "설명입니다"]
+
+    wrapper = CTW(width=15)
+    result = wrapper.wrap(data)
+    assert_equal(expected, result)
+
+
+def test_wrap_wide_char_longword():
+    """TextWrapper: wrapping wide char word that needs to be broken up"""
+    try:
+        import wcwidth  # noqa
+    except ImportError:
+        skip("test_wrap_wide_char_longword is skipped")
+
+    data = "약간감싸면더잘보일수있"
+
+    expected = ["약간", "감싸", "면더", "잘보", "일수", "있"]
+
+    # Explicit odd number to ensure the 2 width is taken into account
+    wrapper = CTW(width=5)
+    result = wrapper.wrap(data)
+    assert_equal(expected, result)
+
+
+def test_wrap_mixed_string():
+    """TextWrapper: wrapping string with mix of wide and non-wide chars"""
+    try:
+        import wcwidth  # noqa
+    except ImportError:
+        skip("test_wrap_wide_char is skipped")
+
+    data = (
+        "This content of this string (この文字列のこの内容) contains "
+        "mulitple character types (複数の文字タイプが含まれています)"
+    )
+
+    expected = [
+        "This content of this",
+        "string (この文字列の",
+        "この内容) contains",
+        "mulitple character",
+        "types (複数の文字タイ",
+        "プが含まれています)",
+    ]
+    wrapper = CTW(width=21)
+    result = wrapper.wrap(data)
+    assert_equal(expected, result)
+
+
+def test_wrapper_len_ignores_color_chars():
+    data = "\033[31m\033[104mtenletters\033[0m"
+    result = CTW._len(data)
+    assert_equal(10, result)
+
+
+def test_wrap_full_line_color():
+    """TextWrapper: Wrap a line when the full thing is enclosed in color tags"""
+    # This has both a text color and a background color
+    data = (
+        "\033[31m\033[104mThis is a test string for testing TextWrap with colors\033[0m"
+    )
+
+    expected = [
+        "\033[31m\033[104mThis is a test\033[0m",
+        "\033[31m\033[104mstring for testing\033[0m",
+        "\033[31m\033[104mTextWrap with colors\033[0m",
+    ]
+    wrapper = CTW(width=20)
+    result = wrapper.wrap(data)
+    assert_equal(expected, result)
+
+
+def test_wrap_color_in_single_line():
+    """TextWrapper: Wrap a line - preserve internal color tags, and don't
+    propogate them to other lines when they don't need to be"""
+    # This has both a text color and a background color
+    data = "This is a test string for testing \033[31mTextWrap\033[0m with colors"
+
+    expected = [
+        "This is a test string for",
+        "testing \033[31mTextWrap\033[0m with",
+        "colors",
+    ]
+    wrapper = CTW(width=25)
+    result = wrapper.wrap(data)
+    assert_equal(expected, result)
+
+
+def test_wrap_color_line_splillover():
+    """TextWrapper: Wrap a line - preserve internal color tags and wrap them to
+    other lines when required, requires adding the colors tags to other lines as appropriate"""
+    # This has both a text color and a background color
+    data = "This is a \033[31mtest string for testing TextWrap\033[0m with colors"
+
+    expected = [
+        "This is a \033[31mtest string for\033[0m",
+        "\033[31mtesting TextWrap\033[0m with",
+        "colors",
+    ]
+    wrapper = CTW(width=25)
+    result = wrapper.wrap(data)
+    assert_equal(expected, result)