"""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\n\n
RegionSchedule
NorthMorningEvening
\n', 'merge-html-rowspan': '\n\n\n\n\n\n
RegionTask
NorthPlan
Ship
\n', 'merge-html-markdown': '\n\n\n
**Schedule**
MorningEvening
\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()