"""Reproduce this guide's local HTML extraction and PPTX object experiment.
Writes only below this script's directory. No network requests, installs,
browser execution, or Office rendering. Existing upstream evidence is retained.
"""
from __future__ import annotations
import argparse
from collections import Counter
from datetime import datetime, timezone
import hashlib
from importlib.metadata import version
import json
from pathlib import Path
import platform
import re
import subprocess
import time
import xml.etree.ElementTree as ET
from zipfile import ZipFile
LAB = Path(__file__).resolve().parent
CORPUS = LAB / "corpus"
SAMPLES = ["article", "article-no-main", "article-css-grid", "article-script-table"]
NS = {"a": "http://schemas.openxmlformats.org/drawingml/2006/main"}
def write_json(path, data):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def relative(path):
return str(path.relative_to(LAB))
def command(args):
started = time.perf_counter()
result = subprocess.run(args, capture_output=True, text=True, check=True)
return result.stdout, result.stderr, round(time.perf_counter() - started, 6)
def build_samples():
original = (CORPUS / "article.html").read_text(encoding="utf-8")
truth = json.loads((CORPUS / "article.truth.json").read_text(encoding="utf-8"))
table = re.search(r"
", original, flags=re.S).group()
cells = re.findall(r"(.*?)", table)
grid = '' + "".join(f'
{x}
' for x in cells) + "
"
css = ""
script = '"
variants = {
"article-no-main": original.replace("", "").replace("", ""),
"article-css-grid": original.replace("", css + "").replace(table, grid),
"article-script-table": original.replace(table, script),
}
for sample, html in variants.items():
(CORPUS / f"{sample}.html").write_text(html, encoding="utf-8")
write_json(CORPUS / f"{sample}.truth.json", truth)
notes = """# HTML-to-PPT experiment corpus
All variants derive from the supplied article.html and its ground truth.
| Sample | Controlled change | Expected browser content, not a rendering claim |
| --- | --- | --- |
| article.html | Supplied static semantic article, main wrapper, table | Article and semantic data table |
| article-no-main.html | Only removes main opening/closing tags | Same article and data, site wrapper still present |
| article-css-grid.html | Replaces table with div cells and CSS Grid | Same cell words arranged by CSS, no semantic table |
| article-script-table.html | Replaces table with empty div and script assigning the original table as innerHTML | Table requires JavaScript execution |
Each same-named truth.json preserves the original target content. Script-table
truth describes the intended content after the script, so its missing cells are
counted as omissions by static extractors. No browser was executed to validate
visual appearance. The synthetic database throughput values are fixture data,
not measurements of database performance or converter speed.
"""
(CORPUS / "HTML-EXPERIMENT.md").write_text(notes, encoding="utf-8")
def count_ast(obj):
found = Counter()
def walk(value):
if isinstance(value, dict):
if "t" in value:
found[value["t"]] += 1
for child in value.values():
walk(child)
elif isinstance(value, list):
for child in value:
walk(child)
walk(obj)
return dict(sorted(found.items()))
def inspect_pptx(path, transcript, xml_output):
from pptx import Presentation
deck = Presentation(str(path))
tables = []
text_shapes = 0
image_shapes = 0
lines = []
slide_titles = []
for slide_index, slide in enumerate(deck.slides, 1):
title_shape = slide.shapes.title
title = title_shape.text if title_shape else "Untitled slide"
slide_titles.append(title)
lines.extend(["# " + title.replace("\n", " "), ""])
for shape in slide.shapes:
if shape.has_table:
table = shape.table
rows = [[cell.text for cell in row.cells] for row in table.rows]
tables.append({"slide": slide_index, "rows": rows, "row_count": len(rows), "column_count": len(table.columns)})
for row_index, row in enumerate(rows):
lines.append("| " + " | ".join(cell.replace("\n", " ") for cell in row) + " |")
if row_index == 0:
lines.append("| " + " | ".join("---" for _ in row) + " |")
lines.append("")
elif shape.has_text_frame:
text_shapes += 1
if shape == title_shape:
continue
for paragraph in shape.text_frame.paragraphs:
if paragraph.text.strip():
lines.extend([paragraph.text, ""])
if shape.shape_type == 13:
image_shapes += 1
xml_parts = []
native_table_elements = 0
with ZipFile(path) as archive:
names = sorted(n for n in archive.namelist() if re.fullmatch(r"ppt/slides/slide\d+\.xml", n))
for name in names:
raw = archive.read(name).decode("utf-8")
native_table_elements += len(ET.fromstring(raw).findall(".//a:tbl", NS))
xml_parts.extend([f"===== {name} =====", raw, ""])
assert native_table_elements == len(tables), "Object and XML table counts disagree"
transcript.parent.mkdir(parents=True, exist_ok=True)
transcript.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
xml_output.write_text("\n".join(xml_parts), encoding="utf-8")
return {
"deck_local_only": relative(path),
"transcript": relative(transcript),
"slide_xml": relative(xml_output),
"slides": len(deck.slides),
"slide_titles": slide_titles,
"native_tables": native_table_elements,
"native_text_shapes": text_shapes,
"image_shapes": image_shapes,
"tables": tables,
"sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
}
def run():
from markitdown import MarkItDown
from markitdown.converters import HtmlConverter
provenance = LAB / "provenance"
provenance.mkdir(exist_ok=True)
upstream = provenance / "upstream-runs.json"
if not upstream.exists():
upstream.write_bytes((LAB / "runs.json").read_bytes())
baseline = {}
for name in ["corpus/article.html", "corpus/article.truth.json", "markovo/article.md", "markovo/article.json"]:
path = LAB / name
baseline[name] = {"sha256": hashlib.sha256(path.read_bytes()).hexdigest(), "bytes": path.stat().st_size}
write_json(provenance / "input-hashes.json", baseline)
build_samples()
pandoc_version = command(["pandoc", "--version"])[0].splitlines()[0]
runner = MarkItDown(enable_builtins=False, enable_plugins=False)
runner.register_converter(HtmlConverter())
results = []
commands = []
structures = []
ast_rows = []
for sample in SAMPLES:
source = CORPUS / f"{sample}.html"
p_dir, m_dir = LAB / "pandoc-html", LAB / "markitdown-html"
p_dir.mkdir(exist_ok=True)
m_dir.mkdir(exist_ok=True)
p_md, m_md = p_dir / f"{sample}.md", m_dir / f"{sample}.md"
args = ["pandoc", str(source), "--from=html", "--to=gfm", "--wrap=none", "--output", str(p_md)]
_, stderr, elapsed = command(args)
(p_dir / f"{sample}.stderr.txt").write_text(stderr, encoding="utf-8")
commands.append(args)
results.append({"tool": "pandoc-html", "sample": sample, "output": relative(p_md), "elapsed_s": elapsed, "error": None})
args = ["pandoc", str(source), "--from=html", "--to=json"]
stdout, stderr, _ = command(args)
ast_path = p_dir / f"{sample}.ast.json"
ast_path.write_text(stdout, encoding="utf-8")
ast_rows.append({"sample": sample, "output": relative(ast_path), "nodes": count_ast(json.loads(stdout))})
commands.append(args)
started = time.perf_counter()
result = runner.convert_local(str(source))
m_md.write_text(result.text_content + "\n", encoding="utf-8")
elapsed = round(time.perf_counter() - started, 6)
results.append({"tool": "markitdown-html", "sample": sample, "output": relative(m_md), "elapsed_s": elapsed, "error": None})
for tool, input_file, reader in [("pandoc-pptx", source, "html"), ("markitdown-pandoc-pptx", m_md, "gfm")]:
out_dir = LAB / tool
out_dir.mkdir(exist_ok=True)
deck = out_dir / f"{sample}.pptx"
transcript = out_dir / f"{sample}.md"
args = ["pandoc", str(input_file), "--from=" + reader, "--to=pptx", "--slide-level=2", "--output", str(deck)]
_, stderr, elapsed = command(args)
(out_dir / f"{sample}.stderr.txt").write_text(stderr, encoding="utf-8")
commands.append(args)
row = inspect_pptx(deck, transcript, out_dir / f"{sample}.slide-xml.txt")
structures.append({"tool": tool, "sample": sample, "elapsed_s": elapsed, **row})
results.append({"tool": tool, "sample": sample, "output": relative(transcript), "elapsed_s": elapsed, "error": None})
snapshot = json.loads((LAB / "markovo/article.json").read_text(encoding="utf-8"))
out_dir = LAB / "pandoc-pptx-no-title"
out_dir.mkdir(exist_ok=True)
args = ["pandoc", str(CORPUS / "article.html"), "--from=html", "--to=pptx", "--slide-level=2", "--metadata=title:", "--output", str(out_dir / "article.pptx")]
_, stderr, elapsed = command(args)
commands.append(args)
(out_dir / "article.stderr.txt").write_text(stderr, encoding="utf-8")
row = inspect_pptx(out_dir / "article.pptx", out_dir / "article.md", out_dir / "article.slide-xml.txt")
structures.append({"tool": "pandoc-pptx-no-title", "sample": "article", "elapsed_s": elapsed, **row})
results.append({"tool": "pandoc-pptx-no-title", "sample": "article", "output": relative(out_dir / "article.md"), "elapsed_s": elapsed, "error": None})
results.append({"tool": "markovo", "sample": "article", "output": "markovo/article.md", "elapsed_s": None, "error": None})
out_dir = LAB / "markovo-pandoc-pptx"
out_dir.mkdir(exist_ok=True)
args = ["pandoc", str(LAB / "markovo/article.md"), "--from=gfm", "--to=pptx", "--slide-level=2", "--output", str(out_dir / "article.pptx")]
_, stderr, elapsed = command(args)
commands.append(args)
(out_dir / "article.stderr.txt").write_text(stderr, encoding="utf-8")
row = inspect_pptx(out_dir / "article.pptx", out_dir / "article.md", out_dir / "article.slide-xml.txt")
structures.append({"tool": "markovo-pandoc-pptx", "sample": "article", "elapsed_s": elapsed, **row})
results.append({"tool": "markovo-pandoc-pptx", "sample": "article", "output": relative(out_dir / "article.md"), "elapsed_s": elapsed, "error": None})
manifest = {
"python": platform.python_version(),
"date": datetime.now(timezone.utc).date().isoformat(),
"scope": "HTML source structure and native PPTX objects; not a PDF/OCR or browser rendering benchmark",
"tools": {
"pandoc-html": {"version": pandoc_version, "options": ["--from=html", "--to=gfm", "--wrap=none"]},
"markitdown-html": {"version": version("markitdown"), "enable_builtins": False, "enable_plugins": False, "registered_converter": "HtmlConverter", "beautifulsoup4": version("beautifulsoup4"), "markdownify": version("markdownify")},
"pandoc-pptx": {"version": pandoc_version, "options": ["--from=html", "--to=pptx", "--slide-level=2"]},
"pandoc-pptx-no-title": {"version": pandoc_version, "options": ["--from=html", "--to=pptx", "--slide-level=2", "--metadata=title:"], "sample_scope": ["article"]},
"markitdown-pandoc-pptx": {"version": pandoc_version, "options": ["--from=gfm", "--to=pptx", "--slide-level=2"]},
"markovo": {"version": snapshot["job"]["rate_card_version"], "created_at": snapshot["job"]["created_at"], "sample_scope": ["article"], "note": "Reused upstream API output. No new API call. Original zero timing discarded, not a measured duration."},
"markovo-pandoc-pptx": {"version": pandoc_version, "note": "Local PPTX generation from supplied upstream Markdown; no API call"},
"pptx_inspector": {"python-pptx": version("python-pptx"), "lxml": version("lxml")},
},
"results": results,
}
write_json(LAB / "runs.json", manifest)
write_json(LAB / "pptx-inspection.json", {"method": "python-pptx shape.has_table and table cells; cross-checked with a:tbl elements in slide XML; no Office rendering/editing", "cells": structures, "html_ast": ast_rows})
write_json(LAB / "commands.json", {"subprocess_commands": commands, "in_process": "MarkItDown(enable_builtins=False, enable_plugins=False), register HtmlConverter(), convert_local(source); see this script", "entrypoint": "PYTHONDONTWRITEBYTECODE=1 tools/lab-venv/bin/python runs/2026-11-04-markovo-html-to-ppt/lab/run_html_to_ppt.py"})
print(json.dumps({"samples": SAMPLES, "scored_outputs": len(results), "decks_inspected": len(structures), "versions": manifest["tools"]}, ensure_ascii=False, indent=2))
def attach_structures():
path = LAB / "scores.json"
scores = json.loads(path.read_text(encoding="utf-8"))
inspection = json.loads((LAB / "pptx-inspection.json").read_text(encoding="utf-8"))
scores["pptx_structures"] = inspection["cells"]
scores["html_ast"] = inspection["html_ast"]
scores["supplement_method"] = "Generated by run_html_to_ppt.py --attach-structures after the unchanged tools/lab_score.py. Original scorer cells and metrics_doc are not altered. Native table counts describe OOXML objects, not visual fidelity or Office editing."
scores["supplement_metrics_doc"] = {"native_tables": "Count of python-pptx table shapes, cross-checked against a:tbl in slide XML", "native_text_shapes": "Count of shapes with text frames including slide titles", "image_shapes": "Count of picture shapes; does not establish rendering quality", "slides": "Number of slide objects", "html_ast": "Counts of Pandoc AST node tags before PPTX generation"}
write_json(path, scores)
print("Attached mechanically measured PPTX/AST structures; original scorer cells unchanged.")
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--attach-structures", action="store_true")
args = parser.parse_args()
if args.attach_structures:
attach_structures()
else:
run()