"""Local HTML-table conversion and Markdown readback; no network access.
Run from repository root with .venv/bin/python. Converter subprocess uses
the already installed tools/lab-venv; renderer uses this interpreter.
"""
import hashlib
import importlib.metadata as im
import json
import platform
import subprocess
import sys
from datetime import datetime, timezone
from html.parser import HTMLParser
from pathlib import Path
from markdown_it import MarkdownIt
LAB = Path(__file__).resolve().parent
ROOT = LAB.parents[2]
LABPY = ROOT / 'tools/lab-venv/bin/python'
OUT = LAB / 'renderers'
OUT.mkdir(exist_ok=True)
SAMPLES = {
'simple': ('Rectangular table with explicit header', '
\n| Group | Item | Qty |
\n| East | Apples | 12 |
\n| West | Pears | 9 |
\n
\n'),
'headerless': ('Body rows without header cells', '\n| East | Apples | 12 |
\n| West | Pears | 9 |
\n
\n'),
'colspan': ('Grouped header spanning two leaf columns', '\n\n| Region | Q1 |
\n| Units | Returns |
\n\n| East | 12 | 1 |
\n
\n'),
'rowspan-left': ('Leftmost category spans rows; missing placeholder shifts data', '\n| Group | Item | Qty |
\n| East | Apples | 12 |
\n| Pears | 9 |
\n
\n'),
'inline': ('Inline code with a pipe and a link', '\n| Expression | Reference |
\na|b | docs |
\n
\n'),
'break': ('Explicit line break in a cell', '\n| Item | Notes |
\n| Apples | Fresh Keep cool |
\n
\n'),
'list': ('Block list inside a cell', '\n'),
'issue-11128': ('Pandoc issue 11128 source table, with span attributes', '\n| Subject | Grade |
\n| Physics | Practical | A |
\n| Theory | B+ |
\n
\n'),
'issue-121': ('Table from markdownify issue 121; span is at right', '\n| Month | Savings | Savings for holiday! |
\n| January | $100 | $50 |
\n| February | $80 |
\n
\n'),
}
class Structure(HTMLParser):
def __init__(self):
super().__init__(convert_charrefs=True)
self.tags, self.attrs, self.rows = {}, [], []
self.row = self.cell = None
def handle_starttag(self, tag, attrs):
self.tags[tag] = self.tags.get(tag, 0) + 1
if tag in ('th', 'td'):
self.cell = {'tag': tag, 'text': '', 'attrs': dict(attrs)}
if self.row is not None:
self.row.append(self.cell)
elif tag == 'tr':
self.row = []
self.rows.append(self.row)
if attrs:
self.attrs.append({'tag': tag, 'attrs': dict(attrs)})
def handle_data(self, data):
if self.cell is not None:
self.cell['text'] += data
def handle_endtag(self, tag):
if tag in ('th', 'td'):
self.cell = None
elif tag == 'tr':
self.row = None
def result(self):
for row in self.rows:
for cell in row:
cell['text'] = ' '.join(cell['text'].split())
return {'tags': self.tags, 'rows': self.rows, 'attributes': self.attrs}
def execute(command, source=None):
return subprocess.run([str(c) for c in command], input=source, text=True,
capture_output=True, check=True)
conv_code = "from pathlib import Path; from markdownify import markdownify; import sys; Path(sys.argv[2]).write_text(markdownify(Path(sys.argv[1]).read_text(),bs4_options='html.parser',table_infer_header=False))"
versions = json.loads(execute([LABPY, '-c', "import importlib.metadata as m,platform,json; print(json.dumps({'markdownify':m.version('markdownify'),'beautifulsoup4':m.version('beautifulsoup4'),'converter_python':platform.python_version()}))"]).stdout)
versions.update({'pandoc': execute(['pandoc', '--version']).stdout.splitlines()[0],
'markdown-it-py': im.version('markdown-it-py'), 'renderer_python': platform.python_version()})
records, conversions = [], []
for sample, (description, html) in SAMPLES.items():
source = OUT / f'{sample}.input.txt'
source.write_text(html)
for tool in ('markdownify', 'pandoc-gfm'):
target = OUT / f'{sample}--{tool}.md'
command = ([LABPY, '-c', conv_code, source, target] if tool == 'markdownify'
else ['pandoc', '-f', 'html', '-t', 'gfm', '--wrap=none', source, '-o', target])
completed = execute(command)
md = target.read_text()
conversions.append({'sample': sample, 'description': description, 'tool': tool,
'input': str(source.relative_to(LAB.parent)),
'output': str(target.relative_to(LAB.parent)),
'sha256': hashlib.sha256(target.read_bytes()).hexdigest(),
'command': [str(c) for c in command], 'stderr': completed.stderr})
rendered = {
'markdown-it-commonmark': MarkdownIt('commonmark', {'html': True}).render(md),
'markdown-it-table': MarkdownIt('commonmark', {'html': True}).enable('table').render(md),
'pandoc-gfm': execute(['pandoc', '-f', 'gfm', '-t', 'html', '--wrap=none'], md).stdout,
}
for renderer, result in rendered.items():
path = OUT / f'{sample}--{tool}--{renderer}.txt'
path.write_text(result)
parser = Structure()
parser.feed(result)
records.append({'sample': sample, 'tool': tool, 'renderer': renderer,
'source': str(target.relative_to(LAB.parent)),
'output': str(path.relative_to(LAB.parent)), **parser.result()})
# Deliberate editorial rewrites, not unedited converter results.
repairs = {
'rowspan-repaired': '| Group | Item | Qty |\n| --- | --- | --- |\n| East | Apples | 12 |\n| East | Pears | 9 |\n',
'inline-repaired': '| Expression | Reference |\n| --- | --- |\n| `a\\|b` | [docs](https://example.com/docs) |\n',
}
for sample, repair in repairs.items():
(OUT / f'{sample}.md').write_text(repair)
for renderer, md in [('markdown-it-table', MarkdownIt('commonmark').enable('table').render(repair)),
('pandoc-gfm', execute(['pandoc', '-f', 'gfm', '-t', 'html', '--wrap=none'], repair).stdout)]:
path = OUT / f'{sample}--{renderer}.txt'
path.write_text(md)
parser = Structure(); parser.feed(md)
records.append({'sample': sample, 'tool': 'editorial-rewrite', 'renderer': renderer,
'source': str((OUT / f'{sample}.md').relative_to(LAB.parent)), 'output': str(path.relative_to(LAB.parent)), **parser.result()})
# Exact writer options from Pandoc issue 11128; record what the installed version does.
issue_path = OUT / 'issue-11128.input.txt'
forced = execute(['pandoc', '-f', 'html', '-t', 'markdown_strict+pipe_tables-raw_html', '--wrap=none', issue_path])
(OUT / 'issue-11128--pandoc-forced-pipes.md').write_text(forced.stdout)
(OUT / 'issue-11128--pandoc-forced-pipes.stderr.txt').write_text(forced.stderr)
source_code = "import inspect,markdownify; print('Installed module:',markdownify.__file__); print('Options:', markdownify.MarkdownConverter.DefaultOptions.table_infer_header); print('\\n'.join(inspect.getsource(getattr(markdownify.MarkdownConverter,n)) for n in ('convert_table','convert_tr','convert_td','convert_th','convert_br','escape')))"
(OUT / 'markdownify-source.txt').write_text(execute([LABPY, '-c', source_code]).stdout)
import inspect
from markdown_it.rules_block.table import table, escapedSplit
(OUT / 'markdown-it-table-source.txt').write_text(inspect.getsource(escapedSplit) + '\n' + inspect.getsource(table))
report = {'kind': 'markdown-rendering', 'date': datetime.now(timezone.utc).isoformat(),
'versions': versions, 'command': '.venv/bin/python runs/2026-10-20-markovo-html-table-to-markdown/lab/table_roundtrip.py',
'scope': 'Synthetic HTML fragments; local implementations only, not GitHub.com or VS Code. HTML tag counts and cell text describe structure, not visual fidelity or accessibility. No OCR or PDF scoring.',
'options': {'markdownify': {'bs4_options': 'html.parser', 'table_infer_header': False},
'pandoc_writer': ['-f', 'html', '-t', 'gfm', '--wrap=none'],
'markdown_it_commonmark': {'html': True}, 'markdown_it_table': "commonmark + enable('table'), html=True",
'pandoc_reader': ['-f', 'gfm', '-t', 'html', '--wrap=none']},
'samples': {k: v[0] for k,v in SAMPLES.items()}, 'conversions': conversions, 'results': records,
'extra_probe': {'sample': 'issue-11128', 'writer': 'markdown_strict+pipe_tables-raw_html',
'output': 'lab/renderers/issue-11128--pandoc-forced-pipes.md', 'stderr': forced.stderr}}
(OUT / 'results.json').write_text(json.dumps(report, indent=2) + '\n')
lines = ['# Local HTML-table roundtrip', '', report['scope'], '', '| Sample | Converter | Reader | table | tr | th | td | br | li |', '|---|---|---|---:|---:|---:|---:|---:|---:|']
for row in records:
t=row['tags']
lines.append('| '+ ' | '.join([row['sample'], row['tool'], row['renderer'], *[str(t.get(k,0)) for k in ('table','tr','th','td','br','li')]])+' |')
(OUT / 'results.md').write_text('\n'.join(lines)+'\n')
print(json.dumps({'kind': report['kind'], 'samples': len(SAMPLES), 'conversions': len(conversions), 'readbacks': len(records), 'versions': versions}))