#!/usr/bin/env python3 """ md_to_html.py — Convert Markdown to a polished, self-contained HTML using Pandoc + huashu-md-html themes. Four themes available: - article : Tufte-inspired editorial (essays, blogs, deep reading) - report : Wide-body publishing-grade (technical reports, whitepapers) - reading : Medium-style minimal (read-only, social repost) - interactive : Long-form with collapsible TOC + sidebar (books, deep guides) Part of huashu-md-html skill — md is source, html is product. """ from __future__ import annotations import argparse import base64 import mimetypes import re import shutil import subprocess import sys from pathlib import Path SKILL_ROOT = Path(__file__).resolve().parent.parent TEMPLATE_DIR = SKILL_ROOT / "templates" VALID_THEMES = ("article", "report", "reading", "interactive", "wechat") HELP_PANDOC = """\ pandoc is not installed. Install it with: brew install pandoc # macOS apt install pandoc # Debian/Ubuntu choco install pandoc # Windows Or download from https://pandoc.org/installing.html """ def ensure_pandoc() -> str: pandoc = shutil.which("pandoc") if not pandoc: sys.stderr.write(HELP_PANDOC) sys.exit(2) return pandoc def parse_args() -> argparse.Namespace: p = argparse.ArgumentParser( description="Convert Markdown to polished HTML using Pandoc + huashu-md-html themes.", ) p.add_argument("input", help="Input .md file path.") p.add_argument( "-o", "--output", help="Output .html path. Defaults to .html.", ) p.add_argument( "--theme", choices=VALID_THEMES, default="article", help="Theme to use (default: article).", ) p.add_argument( "--inline-images", action="store_true", help="Embed referenced local images as base64 (single self-contained file).", ) p.add_argument( "--copy-images", action="store_true", help="Copy referenced local images next to the output html (relative paths).", ) p.add_argument( "--toc", action="store_true", help="Render a table of contents (auto-on for theme=interactive).", ) p.add_argument( "--no-toc", action="store_true", help="Disable TOC even if the theme would default to it.", ) p.add_argument( "--standalone", action="store_true", default=True, help="Produce a self-contained single HTML file (default).", ) p.add_argument( "--katex", action="store_true", help="Render math via KaTeX (loaded from CDN; requires network for first view).", ) p.add_argument( "--highlight-style", default="pygments", help="Pandoc highlight style (default: pygments).", ) p.add_argument( "--title", help="Override the document title (otherwise inferred from first H1 or filename).", ) p.add_argument( "--quiet", action="store_true", help="Suppress non-error stderr output.", ) return p.parse_args() def load_theme(theme: str) -> tuple[Path, Path | None]: theme_dir = TEMPLATE_DIR / theme css_path = theme_dir / "theme.css" if not css_path.exists(): sys.stderr.write(f"[error] theme '{theme}' is missing CSS at: {css_path}\n") sys.exit(2) template_path = theme_dir / "template.html5" return css_path, template_path if template_path.exists() else None def infer_title_and_strip(md_path: Path, override: str | None) -> tuple[str, str]: """Return (title, md_text_with_leading_h1_stripped). Pandoc's --standalone renders a title block from metadata, so we strip the leading H1 from the body to avoid duplicate titles. """ raw = md_path.read_text(encoding="utf-8") lines = raw.splitlines() # Find first non-blank line first_non_blank = next( (i for i, line in enumerate(lines) if line.strip()), None, ) title = override body_lines = lines if first_non_blank is not None: first_line = lines[first_non_blank].strip() if first_line.startswith("# ") and not first_line.startswith("## "): extracted = first_line[2:].strip() if not title: title = extracted # Strip the H1 line and any blank lines that immediately follow it cut = first_non_blank + 1 while cut < len(lines) and not lines[cut].strip(): cut += 1 body_lines = lines[:first_non_blank] + lines[cut:] if not title: title = md_path.stem return title, "\n".join(body_lines) def should_emit_toc(args: argparse.Namespace) -> bool: if args.no_toc: return False if args.toc: return True return args.theme == "interactive" def collect_local_images(md_text: str, base_dir: Path) -> list[Path]: """Find referenced local images in markdown that exist on disk.""" pattern = re.compile(r'!\[[^\]]*\]\(([^)\s"]+)') found: list[Path] = [] for m in pattern.finditer(md_text): ref = m.group(1) if ref.startswith(("http://", "https://", "data:")): continue candidate = (base_dir / ref).resolve() if candidate.exists() and candidate.is_file(): found.append(candidate) return found def inline_images_in_html(html: str, base_dir: Path) -> str: """Replace local img src references with base64 data URIs.""" pattern = re.compile(r'(]+src=)"([^"]+)"') def replace(match: re.Match[str]) -> str: prefix, src = match.group(1), match.group(2) if src.startswith(("http://", "https://", "data:")): return match.group(0) candidate = (base_dir / src).resolve() if not candidate.exists(): return match.group(0) mime, _ = mimetypes.guess_type(candidate) if not mime: mime = "application/octet-stream" b64 = base64.b64encode(candidate.read_bytes()).decode("ascii") return f'{prefix}"data:{mime};base64,{b64}"' return pattern.sub(replace, html) def copy_images_alongside(images: list[Path], src_base: Path, out_dir: Path, quiet: bool) -> None: out_dir.mkdir(parents=True, exist_ok=True) for img in images: try: relative = img.relative_to(src_base) except ValueError: relative = Path(img.name) dest = out_dir / relative dest.parent.mkdir(parents=True, exist_ok=True) if img.resolve() != dest.resolve(): shutil.copy2(img, dest) if not quiet: sys.stderr.write(f"[copied] {img} → {dest}\n") def build_pandoc_command( pandoc_bin: str, md_input: Path | str, output_html: Path, css_path: Path, template_path: Path | None, title: str, args: argparse.Namespace, ) -> list[str]: cmd = [ pandoc_bin, str(md_input), "--from", "markdown+smart+pipe_tables+task_lists+fenced_divs+bracketed_spans", "--to", "html5", "--metadata", f"title={title}", "--metadata", f"theme={args.theme}", "--highlight-style", args.highlight_style, "-o", str(output_html), ] if args.standalone: cmd.append("--standalone") cmd.extend(["--css", str(css_path)]) if template_path: cmd.extend(["--template", str(template_path)]) if should_emit_toc(args): cmd.extend(["--toc", "--toc-depth=3"]) if args.katex: cmd.append("--katex") return cmd def main() -> int: pandoc_bin = ensure_pandoc() args = parse_args() input_md = Path(args.input).resolve() if not input_md.exists(): sys.stderr.write(f"[error] input file not found: {input_md}\n") return 1 output_html = Path(args.output).resolve() if args.output else input_md.with_suffix(".html") output_html.parent.mkdir(parents=True, exist_ok=True) css_path, template_path = load_theme(args.theme) title, md_text = infer_title_and_strip(input_md, args.title) src_base = input_md.parent if args.copy_images: images = collect_local_images(md_text, src_base) if images: copy_images_alongside(images, src_base, output_html.parent, args.quiet) # Pipe the stripped markdown to pandoc via stdin to avoid temp files cmd = build_pandoc_command( pandoc_bin, "-", output_html, css_path, template_path, title, args, ) try: proc = subprocess.run(cmd, input=md_text, capture_output=True, text=True) except FileNotFoundError: sys.stderr.write(HELP_PANDOC) return 2 if proc.returncode != 0: sys.stderr.write(proc.stderr or "[error] pandoc failed without stderr output.\n") return proc.returncode if args.inline_images: html_text = output_html.read_text(encoding="utf-8") html_text = inline_images_in_html(html_text, src_base) output_html.write_text(html_text, encoding="utf-8") if not args.quiet: sys.stderr.write(f"[ok] {input_md.name} → {output_html} (theme: {args.theme})\n") return 0 if __name__ == "__main__": raise SystemExit(main())