"""Offline table span probe. Run from the repository root with .venv/bin/python.
Adds merge-specific observations without changing the upstream renderer rows.
Counts describe parsed HTML structure, not a browser layout or platform test.
"""
from __future__ import annotations
import hashlib
import importlib.metadata
import inspect
import json
import platform
import subprocess
from datetime import datetime, timezone
from html.parser import HTMLParser
from pathlib import Path
import markdown
from markdown.extensions.tables import TableProcessor
from markdown_it import MarkdownIt
from markdown_it.rules_block.table import table
OUT = Path(__file__).resolve().parent
COMMAND = '.venv/bin/python runs/2026-11-02-markovo-markdown-table-merge-cells/lab/renderers/test_merges.py'
SAMPLES = {
'merge-double-pipe': '| Region | Morning | Evening |\n| --- | --- | --- |\n| North | Shared ||\n',
'merge-short-row': '| Region | Morning | Evening |\n| --- | --- | --- |\n| North | Shared |\n',
'merge-overfull-row': '| Region | Task |\n| --- | --- |\n| North | Plan | Ship |\n',
'merge-plugin-marker': '| Region | Task |\n| --- | --- |\n| North | Plan |\n| ^^ | Ship |\n',
'merge-html-colspan': '
\n\n| Region | Schedule |
\n| North | Morning | Evening |
\n
\n',
'merge-html-rowspan': '\n| Region | Task |
\n\n| North | Plan |
\n| Ship |
\n\n
\n',
'merge-html-markdown': '\n| **Schedule** |
\n| Morning | Evening |
\n
\n',
'merge-flat-labels': '| Region | Task |\n| --- | --- |\n| North | Plan |\n| North | Ship |\n',
}
class Structure(HTMLParser):
def __init__(self):
super().__init__()
self.counts = {}
self.spans = []
self.cells = []
self.current = None
def handle_starttag(self, tag, attrs):
self.counts[tag] = self.counts.get(tag, 0) + 1
attrs = dict(attrs)
if tag in ('td', 'th'):
self.current = {'tag': tag, 'text': '', 'attrs': attrs}
self.cells.append(self.current)
for name in ('colspan', 'rowspan'):
if name in attrs:
self.spans.append({'tag': tag, 'attribute': name, 'value': attrs[name]})
def handle_data(self, data):
if self.current is not None:
self.current['text'] += data
def handle_endtag(self, tag):
if tag in ('td', 'th'):
self.current = None
def digest(path):
return hashlib.sha256(path.read_bytes()).hexdigest()
def main():
result_file = OUT / 'results.json'
report = json.loads(result_file.read_text())
report['results'] = [r for r in report['results'] if r['sample'] not in SAMPLES]
renderers = {
'markdown-it-commonmark': (lambda s: MarkdownIt('commonmark').render(s)),
'markdown-it-table': (lambda s: MarkdownIt('commonmark').enable('table').render(s)),
'markdown-it-nohtml': (lambda s: MarkdownIt('commonmark', {'html': False}).enable('table').render(s)),
'python-markdown': (lambda s: markdown.markdown(s, extensions=['tables', 'fenced_code'])),
'pandoc-gfm': (lambda s: subprocess.run(['pandoc', '-f', 'gfm', '-t', 'html'], input=s,
text=True, capture_output=True, check=True).stdout),
}
rows = []
for sample, source in SAMPLES.items():
(OUT / f'{sample}.md').write_text(source)
for renderer, render in renderers.items():
html = render(source)
output = OUT / f'{sample}--{renderer}.txt'
output.write_text(html)
structure = Structure()
structure.feed(html)
rows.append({'sample': sample, 'renderer': renderer, 'tags': structure.counts,
'spans': structure.spans, 'cells': structure.cells,
'empty_cells': sum(not c['text'].strip() for c in structure.cells),
'source': f'lab/renderers/{sample}.md',
'output': f'lab/renderers/{output.name}',
'source_sha256': digest(OUT / f'{sample}.md'), 'output_sha256': digest(output)})
report['results'].extend(rows)
report['supplement'] = {
'kind': 'markdown-rendering', 'date': datetime.now(timezone.utc).isoformat(),
'python': platform.python_version(), 'command': COMMAND,
'versions': {'markdown-it-py': importlib.metadata.version('markdown-it-py'),
'Markdown': markdown.__version__,
'pandoc': subprocess.check_output(['pandoc', '--version'], text=True).splitlines()[0]},
'options': {'markdown-it-commonmark': 'commonmark preset; table disabled; html=True',
'markdown-it-table': 'commonmark preset; enable(table); html=True',
'markdown-it-nohtml': 'commonmark preset; enable(table); html=False (escaping, not sanitizing)',
'python-markdown': 'extensions=[tables, fenced_code]; no md_in_html',
'pandoc-gfm': 'pandoc -f gfm -t html; no filters'},
'definitions': {'tags': 'HTMLParser start-tag count, including th/td, not rendered grid positions',
'spans': 'colspan/rowspan attribute occurrences on th or td, with literal value',
'empty_cells': 'parsed th/td with whitespace-only text',
'cells': 'parsed cell text and attributes in document order'},
'scope': 'Synthetic authored tables only. No GitHub.com, VS Code, Obsidian GUI, plugin execution, browser geometry, accessibility testing, or CSV conversion benchmark.',
'script_sha256': digest(Path(__file__)),
}
result_file.write_text(json.dumps(report, indent=2) + '\n')
lines = ['# Merge-specific observations', '', report['supplement']['scope'], '',
'| Sample | Renderer | table | tr | th+td | colspan | rowspan | empty |',
'| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |']
for row in rows:
c = row['tags']
span = lambda name: sum(s['attribute'] == name for s in row['spans'])
lines.append(f"| {row['sample']} | {row['renderer']} | {c.get('table',0)} | {c.get('tr',0)} | {len(row['cells'])} | {span('colspan')} | {span('rowspan')} | {row['empty_cells']} |")
(OUT / 'merge-results.md').write_text('\n'.join(lines) + '\n')
facts = []
for obj in (TableProcessor._build_row, table):
path = Path(inspect.getsourcefile(obj))
source, start = inspect.getsourcelines(obj)
facts += [f'FILE: {path.name}; package: {"Markdown" if obj is TableProcessor._build_row else "markdown-it-py"}',
f'FUNCTION: {obj.__qualname__}; source file sha256: {digest(path)}',
*[f'{start+i}: {line.rstrip()}' for i, line in enumerate(source)], '']
(OUT / 'source-facts.txt').write_text('\n'.join(facts))
print(json.dumps({'cases': len(rows), 'samples': len(SAMPLES), 'versions': report['supplement']['versions'],
'results': str(result_file), 'scope': report['supplement']['scope']}, indent=2))
if __name__ == '__main__':
main()