Skip to content

Instantly share code, notes, and snippets.

@cmbuckley
Last active August 7, 2026 08:32
Show Gist options
  • Select an option

  • Save cmbuckley/3fe36fc9ff771d540add7b749539c98a to your computer and use it in GitHub Desktop.

Select an option

Save cmbuckley/3fe36fc9ff771d540add7b749539c98a to your computer and use it in GitHub Desktop.
Markdown table to RST grid table
#!/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