"""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\n\n\n
GroupItemQty
EastApples12
WestPears9
\n'), 'headerless': ('Body rows without header cells', '\n\n\n
EastApples12
WestPears9
\n'), 'colspan': ('Grouped header spanning two leaf columns', '\n\n\n\n\n\n
RegionQ1
UnitsReturns
East121
\n'), 'rowspan-left': ('Leftmost category spans rows; missing placeholder shifts data', '\n\n\n\n
GroupItemQty
EastApples12
Pears9
\n'), 'inline': ('Inline code with a pipe and a link', '\n\n\n
ExpressionReference
a|bdocs
\n'), 'break': ('Explicit line break in a cell', '\n\n\n
ItemNotes
ApplesFresh
Keep cool
\n'), 'list': ('Block list inside a cell', '\n\n\n
ItemSteps
Apples
  • Wash
  • Slice
\n'), 'issue-11128': ('Pandoc issue 11128 source table, with span attributes', '\n\n\n\n
SubjectGrade
PhysicsPracticalA
TheoryB+
\n'), 'issue-121': ('Table from markdownify issue 121; span is at right', '\n\n\n\n
MonthSavingsSavings for holiday!
January$100$50
February$80
\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}))