Last active
August 7, 2026 08:32
-
-
Save cmbuckley/3fe36fc9ff771d540add7b749539c98a to your computer and use it in GitHub Desktop.
Markdown table to RST grid table
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| Convert Markdown tables to reStructuredText grid tables. | |
| Features: | |
| - Detects standard Markdown tables. | |
| - Wraps cell contents to a configurable width (default: 50 chars). | |
| - Only converts tables whose original width exceeds a configurable | |
| threshold (default: 140 chars). | |
| - Produces valid reStructuredText grid tables. | |
| - Handles multiple tables in a document. | |
| - Leaves non-table Markdown untouched. | |
| Usage: | |
| python md_table_to_rst.py input.md > output.rst | |
| Options: | |
| --width N Maximum wrapped cell width (default: 50) | |
| --min-table-width N Only convert tables wider than this | |
| (default: 140) | |
| Or: | |
| cat input.md | python md_table_to_rst.py | |
| """ | |
| import argparse | |
| import re | |
| import sys | |
| import textwrap | |
| def split_md_row(line): | |
| """Split a markdown table row into cells.""" | |
| line = line.strip() | |
| if line.startswith("|"): | |
| line = line[1:] | |
| if line.endswith("|"): | |
| line = line[:-1] | |
| return [c.strip() for c in line.split("|")] | |
| def is_separator(line): | |
| """Return True if line is a markdown separator.""" | |
| cells = split_md_row(line) | |
| return all(re.fullmatch(r":?-{3,}:?", c) for c in cells) | |
| def wrap_cells(rows, max_width): | |
| """Wrap all cells and determine column widths.""" | |
| wrapped_rows = [] | |
| ncols = max(len(r) for r in rows) | |
| for row in rows: | |
| row = row + [""] * (ncols - len(row)) | |
| wrapped = [] | |
| for cell in row: | |
| if not cell: | |
| wrapped.append([""]) | |
| else: | |
| wrapped.append( | |
| textwrap.wrap( | |
| cell, | |
| width=max_width, | |
| break_long_words=False, | |
| break_on_hyphens=False, | |
| ) or [""] | |
| ) | |
| wrapped_rows.append(wrapped) | |
| widths = [] | |
| for col in range(ncols): | |
| width = max( | |
| len(line) | |
| for row in wrapped_rows | |
| for line in row[col] | |
| ) | |
| widths.append(width) | |
| return wrapped_rows, widths | |
| def border(widths, header=False): | |
| ch = "=" if header else "-" | |
| return "+" + "+".join(ch * (w + 2) for w in widths) + "+" | |
| def format_rst(rows, max_width=50): | |
| """Format rows as an rst grid table.""" | |
| wrapped_rows, widths = wrap_cells(rows, max_width) | |
| out = [border(widths)] | |
| for rnum, row in enumerate(wrapped_rows): | |
| height = max(len(cell) for cell in row) | |
| for i in range(height): | |
| line = "|" | |
| for col, width in enumerate(widths): | |
| txt = row[col][i] if i < len(row[col]) else "" | |
| line += " " + txt.ljust(width) + " |" | |
| out.append(line) | |
| if rnum == 0: | |
| out.append(border(widths, header=True)) | |
| else: | |
| out.append(border(widths)) | |
| return "\n".join(out) | |
| def markdown_table_width(rows): | |
| """ | |
| Estimate the rendered width of the original markdown table. | |
| """ | |
| ncols = max(len(r) for r in rows) | |
| widths = [] | |
| for col in range(ncols): | |
| widths.append( | |
| max( | |
| len(row[col]) if col < len(row) else 0 | |
| for row in rows | |
| ) | |
| ) | |
| # Width of: | cell | cell | | |
| return sum(widths) + (3 * ncols) + 1 | |
| def looks_like_table(lines, idx): | |
| if idx + 1 >= len(lines): | |
| return False | |
| if "|" not in lines[idx]: | |
| return False | |
| return is_separator(lines[idx + 1]) | |
| def consume_table(lines, idx): | |
| rows = [split_md_row(lines[idx])] | |
| idx += 2 # Skip header and separator | |
| while idx < len(lines): | |
| line = lines[idx] | |
| if not line.strip() or "|" not in line: | |
| break | |
| rows.append(split_md_row(line)) | |
| idx += 1 | |
| return rows, idx | |
| def markdown_table_text(rows): | |
| """Format rows as a neatly aligned Markdown table.""" | |
| ncols = max(len(r) for r in rows) | |
| # Pad all rows to the same number of columns. | |
| rows = [r + [""] * (ncols - len(r)) for r in rows] | |
| # Determine column widths. | |
| widths = [] | |
| for col in range(ncols): | |
| widths.append(max(len(row[col]) for row in rows)) | |
| out = [] | |
| # Header. | |
| out.append( | |
| "| " + " | ".join( | |
| rows[0][i].ljust(widths[i]) | |
| for i in range(ncols) | |
| ) + " |" | |
| ) | |
| # Separator. | |
| sep = [] | |
| for w in widths: | |
| sep.append("-" * max(3, w)) | |
| out.append("|-" + "-|-".join(sep) + "-|") | |
| # Body. | |
| for row in rows[1:]: | |
| out.append( | |
| "| " + " | ".join( | |
| row[i].ljust(widths[i]) | |
| for i in range(ncols) | |
| ) + " |" | |
| ) | |
| return "\n".join(out) | |
| def convert_document(text, max_width=50, min_table_width=140): | |
| lines = text.splitlines() | |
| out = [] | |
| i = 0 | |
| while i < len(lines): | |
| if looks_like_table(lines, i): | |
| rows, i = consume_table(lines, i) | |
| if markdown_table_width(rows) > min_table_width: | |
| out.append(format_rst(rows, max_width)) | |
| else: | |
| out.append(markdown_table_text(rows)) | |
| else: | |
| out.append(lines[i]) | |
| i += 1 | |
| return "\n".join(out) | |
| def main(): | |
| parser = argparse.ArgumentParser() | |
| parser.add_argument( | |
| "input", | |
| nargs="?", | |
| help="Markdown file (defaults to stdin)", | |
| ) | |
| parser.add_argument( | |
| "--width", | |
| type=int, | |
| default=50, | |
| help="Maximum wrapped cell width (default: 50)", | |
| ) | |
| parser.add_argument( | |
| "--min-table-width", | |
| type=int, | |
| default=140, | |
| help="Only convert tables whose original markdown width exceeds this " | |
| "(default: 140)", | |
| ) | |
| args = parser.parse_args() | |
| if args.input: | |
| with open(args.input, encoding="utf-8") as f: | |
| text = f.read() | |
| else: | |
| text = sys.stdin.read() | |
| print( | |
| convert_document( | |
| text, | |
| max_width=args.width, | |
| min_table_width=args.min_table_width, | |
| ) | |
| ) | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment