|
#!/usr/bin/env python3 |
|
import argparse |
|
import os |
|
import shutil |
|
import subprocess |
|
import sys |
|
from pathlib import Path |
|
import json |
|
from typing import List, Optional, Tuple, Set |
|
|
|
|
|
def detect_main_tex(tex_path: Path) -> bool: |
|
""" |
|
Heuristic: consider a .tex file a main document if it contains \\documentclass |
|
in the first ~200 lines ignoring comment-only lines. |
|
""" |
|
try: |
|
with tex_path.open('r', encoding='utf-8', errors='ignore') as f: |
|
for i, line in enumerate(f): |
|
if i > 200: |
|
break |
|
s = line.strip() |
|
if not s or s.startswith('%'): |
|
continue |
|
if '\\documentclass' in s: |
|
return True |
|
return False |
|
except Exception: |
|
return False |
|
|
|
|
|
def which(cmd: str) -> Optional[str]: |
|
return shutil.which(cmd) |
|
|
|
|
|
def run(cmd: List[str], cwd: Path, env: dict, timeout: int) -> Tuple[int, str, str]: |
|
proc = subprocess.Popen( |
|
cmd, |
|
cwd=str(cwd), |
|
env=env, |
|
stdout=subprocess.PIPE, |
|
stderr=subprocess.PIPE, |
|
text=True, |
|
) |
|
try: |
|
out, err = proc.communicate(timeout=timeout) |
|
return proc.returncode, out, err |
|
except subprocess.TimeoutExpired: |
|
proc.kill() |
|
out, err = proc.communicate() |
|
return 124, out, err + "\n[timeout]" |
|
|
|
|
|
def _collect_local_links(html: Path) -> Set[str]: |
|
"""Parse a simple set of local asset links (src/href) from an HTML file.""" |
|
import re |
|
|
|
data = html.read_text(encoding='utf-8', errors='ignore') |
|
links: Set[str] = set() |
|
for m in re.finditer(r"(?:src|href)\s*=\s*['\"]([^'\"]+)['\"]", data, flags=re.IGNORECASE): |
|
href = m.group(1).strip() |
|
if not href or href.startswith('#'): |
|
continue |
|
if '://' in href or href.startswith('mailto:'): |
|
continue |
|
if href.startswith('data:'): |
|
continue |
|
# Avoid absolute paths; keep relative only |
|
if href.startswith('/'): |
|
continue |
|
links.add(href) |
|
return links |
|
|
|
|
|
def _inject_overrides(html_file: Path, css_name: str, mj_snippet: Optional[str]) -> None: |
|
try: |
|
html = html_file.read_text(encoding='utf-8', errors='ignore') |
|
except Exception: |
|
return |
|
|
|
lines = [] |
|
inserted_css = False |
|
inserted_mj = False |
|
|
|
mj_present = ('MathJax' in html) |
|
css_link = f'<link rel="stylesheet" type="text/css" href="{css_name}" />' |
|
|
|
for line in html.splitlines(): |
|
# After existing CSS, add our override link once |
|
if (not inserted_css) and ('</head>' in line or '<meta name="src"' in line or ('stylesheet' in line and '.css' in line)): |
|
lines.append(line) |
|
if 'stylesheet' in line and '.css' in line: |
|
lines.append(css_link) |
|
inserted_css = True |
|
elif '</head>' in line: |
|
lines.insert(max(0, len(lines)-1), css_link) |
|
inserted_css = True |
|
continue |
|
# Right before </head>, inject MathJax if requested and not present |
|
if (mj_snippet is not None) and (not mj_present) and (not inserted_mj) and '</head>' in line: |
|
lines.append(mj_snippet) |
|
inserted_mj = True |
|
lines.append(line) |
|
|
|
# Fallbacks if not inserted |
|
if not inserted_css: |
|
lines.insert(0, css_link) |
|
if (mj_snippet is not None) and (not mj_present) and (not inserted_mj): |
|
lines.insert(0, mj_snippet) |
|
|
|
try: |
|
html_file.write_text('\n'.join(lines), encoding='utf-8') |
|
except Exception: |
|
pass |
|
|
|
|
|
def _inject_in_all_html(out_dir: Path, mj_snippet: Optional[str]) -> None: |
|
fix_css = out_dir / 'zzz_overrides.css' |
|
css_snippet = ( |
|
'/* overrides */\n' |
|
'div.center, div.center div.center { text-align: center; }\n' |
|
'div.center { margin-left: 0 !important; margin-right: 0 !important; }\n' |
|
'figure.figure { margin-left: auto; margin-right: auto; }\n' |
|
'/* Upscale figure images produced by tex4ht (often carry tiny width/height attributes) */\n' |
|
'img[alt="PIC"] { display:block; width: auto !important; height: auto !important; max-width: min(100%, 900px) !important; margin: 1rem auto; }\n' |
|
) |
|
try: |
|
if not fix_css.exists(): |
|
fix_css.write_text(css_snippet, encoding='utf-8') |
|
else: |
|
cur = fix_css.read_text(encoding='utf-8', errors='ignore') |
|
# If an older rule was written earlier, append the improved sizing constraints |
|
if 'max-width: min(100%' not in cur: |
|
fix_css.write_text(cur.rstrip() + '\n' + css_snippet, encoding='utf-8') |
|
except Exception: |
|
return |
|
for html in sorted(out_dir.glob('*.html')): |
|
_inject_overrides(html, fix_css.name, mj_snippet=mj_snippet) |
|
|
|
|
|
def try_tex4ht_html(tex: Path, out_dir: Path, env: dict, timeout: int = 3600, math_output: str = 'mathjax', shell_escape: bool = True, engine: str = 'pdflatex', extra_latex_opts: str = '', jobname: Optional[str] = None) -> Tuple[bool, str, Optional[Path]]: |
|
""" |
|
Convert a LaTeX document to HTML using tex4ht tools. |
|
|
|
Preference order: |
|
1) make4ht (modern wrapper) |
|
2) htlatex (legacy wrapper) |
|
|
|
We run in the source directory to preserve relative includes, and |
|
then copy the produced HTML and assets into out_dir. |
|
""" |
|
out_dir.mkdir(parents=True, exist_ok=True) |
|
|
|
logs: List[str] = [] |
|
|
|
def locate_html(and_from_parent: bool = True) -> Optional[Path]: |
|
candidates = [ |
|
out_dir / 'index.html', |
|
out_dir / f'{tex.stem}.html', |
|
] |
|
if and_from_parent: |
|
candidates.extend([ |
|
tex.parent / 'index.html', |
|
tex.parent / f'{tex.stem}.html', |
|
]) |
|
for c in candidates: |
|
if c.exists() and c.stat().st_size > 0: |
|
return c |
|
found = list(out_dir.rglob('*.html')) |
|
if found: |
|
return found[0] |
|
if and_from_parent: |
|
found = list(tex.parent.rglob('*.html')) |
|
if found: |
|
return found[0] |
|
return None |
|
|
|
def copy_results(html_path: Path) -> Path: |
|
# Copy HTML to out_dir if needed |
|
if html_path.parent != out_dir: |
|
target_html = out_dir / html_path.name |
|
target_html.write_text(html_path.read_text(encoding='utf-8', errors='ignore'), encoding='utf-8') |
|
|
|
# Copy linked local assets |
|
for rel in sorted(_collect_local_links(html_path)): |
|
src = (html_path.parent / rel).resolve() |
|
if src.is_file(): |
|
dst = out_dir / rel |
|
dst.parent.mkdir(parents=True, exist_ok=True) |
|
try: |
|
shutil.copy2(src, dst) |
|
except Exception: |
|
pass |
|
# Copy <stem>/ assets directory if present |
|
assets_dir = html_path.parent / tex.stem |
|
if assets_dir.exists() and assets_dir.is_dir(): |
|
dst_assets_dir = out_dir / assets_dir.name |
|
shutil.copytree(assets_dir, dst_assets_dir, dirs_exist_ok=True) |
|
return target_html |
|
return html_path |
|
|
|
# 1) Try make4ht |
|
exe = which('make4ht') |
|
if exe: |
|
# Use HTML5 output; choose math extension |
|
fmt = f"html5+{'mathml' if math_output == 'mathml' else 'mathjax'}" |
|
# Try to propagate -shell-escape to LaTeX used by make4ht |
|
env2 = env.copy() |
|
if shell_escape: |
|
latex_cmd = f"{engine} -shell-escape" |
|
else: |
|
latex_cmd = engine |
|
env2['LATEX'] = latex_cmd |
|
env2['TEX'] = latex_cmd |
|
# Pass LaTeX options explicitly as the 4th positional argument too, |
|
# since make4ht may ignore LATEX/TEX env vars in some setups. |
|
latex_opts = '-interaction=nonstopmode' |
|
if extra_latex_opts: |
|
latex_opts += f' {extra_latex_opts}' |
|
if shell_escape: |
|
latex_opts += ' -shell-escape' |
|
cmd = [exe] |
|
if shell_escape: |
|
cmd.append('-s') # enable shell-escape in LaTeX runs |
|
if jobname: |
|
cmd.extend(['-j', jobname]) |
|
cmd.extend(['-d', str(out_dir), '-f', fmt, tex.name, '', '', latex_opts]) |
|
code, out, err = run(cmd, cwd=tex.parent, env=env2, timeout=timeout) |
|
# Fallback: if mathml requested but extension is missing, retry with mathjax |
|
if (math_output == 'mathml') and ('Cannot load extension: mathml' in (out + err)): |
|
fmt = 'html5+mathjax' |
|
cmd = [exe] |
|
if shell_escape: |
|
cmd.append('-s') |
|
if jobname: |
|
cmd.extend(['-j', jobname]) |
|
cmd.extend(['-d', str(out_dir), '-f', fmt, tex.name, '', '', latex_opts]) |
|
code, out2, err2 = run(cmd, cwd=tex.parent, env=env2, timeout=timeout) |
|
out, err = out + "\n[FALLBACK to mathjax]\n" + out2, err + err2 |
|
logs.append(f"== make4ht ==\n$ {' '.join(cmd)}\n\n[stdout]\n{out}\n\n[stderr]\n{err}\n") |
|
html_path = locate_html(and_from_parent=True) |
|
# Detect minted/shell-escape issues and force fallback to htlatex even if make4ht produced output |
|
minted_fail = ('Package minted Error' in (out + err)) or ('You must invoke LaTeX with the -shell-escape flag' in (out + err)) or ('Missing Pygments output' in (out + err)) |
|
if (code == 0) and (html_path is not None) and (not minted_fail): |
|
copied = copy_results(html_path) |
|
# Add overrides & MathJax to all paginated pages in this doc dir |
|
if math_output == 'mathml': |
|
mj_snippet = ( |
|
'<script>window.MathJax = {svg:{fontCache:"global"}};</script>' |
|
'<script async src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/mml-svg.js"></script>' |
|
) |
|
else: |
|
mj_snippet = ( |
|
'<script>window.MathJax = {tex:{inlineMath:[["$","$"],["\\(","\\)"]]},svg:{fontCache:"global"}};</script>' |
|
'<script async src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js"></script>' |
|
) |
|
_inject_in_all_html(out_dir, mj_snippet=mj_snippet) |
|
(out_dir / 'build.log').write_text('\n\n'.join(logs), encoding='utf-8') |
|
return True, 'ok', copied |
|
elif minted_fail: |
|
logs.append('[[ make4ht produced output but with minted/shell-escape errors; falling back to htlatex ]]') |
|
|
|
# 2) Fallback to htlatex (run a couple of times to stabilize refs) |
|
exe = which('htlatex') |
|
if not exe: |
|
(out_dir / 'build.log').write_text('\n\n'.join(logs + ['htlatex not found in PATH'] ), encoding='utf-8') |
|
return False, 'tex4ht not available', None |
|
|
|
log_parts: List[str] = [] |
|
# Prepare env for htlatex: some wrappers respect LATEX/TEX env |
|
env_ht = env.copy() |
|
if shell_escape: |
|
env_ht['LATEX'] = f"{engine} -shell-escape" |
|
env_ht['TEX'] = f"{engine} -shell-escape" |
|
for i in range(2): |
|
# htlatex expects arguments in this order: |
|
# htlatex <file> "<tex4ht opts>" "<t4ht opts>" "<latex opts>" |
|
# Pass output dir via t4ht opts (-d...) and LaTeX options in the 4th arg. |
|
latex_opts = '-interaction=nonstopmode' |
|
if extra_latex_opts: |
|
latex_opts += f' {extra_latex_opts}' |
|
if shell_escape: |
|
latex_opts += ' -shell-escape' |
|
cmd = [exe, tex.name, 'html,2', f'-d{str(out_dir)}', latex_opts] |
|
code, out, err = run(cmd, cwd=tex.parent, env=env_ht, timeout=timeout) |
|
log_parts.append(f"# pass {i+1}\n$ {' '.join(cmd)}\n\n[stdout]\n{out}\n\n[stderr]\n{err}\n") |
|
# continue even if code != 0 to try to produce partial output |
|
|
|
logs.append("== htlatex ==\n" + '\n'.join(log_parts)) |
|
html_path = locate_html(and_from_parent=True) |
|
ok = html_path is not None and html_path.exists() |
|
copied = copy_results(html_path) if html_path else None |
|
# Add CSS overrides; MathJax injection is kept optional but htlatex output normally uses images |
|
if copied is not None: |
|
_inject_in_all_html(out_dir, mj_snippet=None) |
|
(out_dir / 'build.log').write_text('\n\n'.join(logs), encoding='utf-8') |
|
return ok, ('ok' if ok else 'fail'), copied |
|
|
|
|
|
|
|
|
|
|
|
def convert_one(tex: Path, src_root: Path, dst_root: Path, clean: bool = False, math_output: str = 'mathjax', shell_escape: bool = True, engine: str = 'pdflatex') -> Tuple[bool, str, Path]: |
|
rel_parent = tex.parent.relative_to(src_root) |
|
out_dir = dst_root / rel_parent / tex.stem |
|
if clean and out_dir.exists(): |
|
try: |
|
shutil.rmtree(out_dir) |
|
except Exception: |
|
pass |
|
out_dir.mkdir(parents=True, exist_ok=True) |
|
|
|
# Prepare environment: extend TEXINPUTS so LaTeX can find includes |
|
env = os.environ.copy() |
|
texinputs = env.get('TEXINPUTS', '') |
|
extra_paths = [str(tex.parent), str(src_root), str(src_root.parent)] |
|
env['TEXINPUTS'] = os.pathsep.join(extra_paths + [texinputs, '']) # trailing '' adds default path |
|
# Ensure pygmentize and other user-level tools are discoverable |
|
home = Path.home() |
|
user_bin = str(home / '.local' / 'bin') |
|
repo_bin = str(Path.cwd() / 'bin') |
|
env['PATH'] = os.pathsep.join([user_bin, repo_bin, env.get('PATH', '')]) |
|
|
|
# If the source uses minted, create a lightweight wrapper that freezes cache |
|
extra_latex_opts = '' |
|
jobname = None |
|
tex_for_build = tex |
|
try: |
|
uses_minted = '\\usepackage{minted}' in tex.read_text(encoding='utf-8', errors='ignore') |
|
except Exception: |
|
uses_minted = False |
|
if uses_minted: |
|
wrapper = tex.parent / f"{tex.stem}_htmlwrap.tex" |
|
try: |
|
wrapper.write_text( |
|
'% auto-generated wrapper for HTML conversion\n' |
|
f'\\PassOptionsToPackage{{frozencache,cachedir=_minted-{tex.stem}}}{{minted}}\n' |
|
f'\\input{{{tex.name}}}\n', |
|
encoding='utf-8' |
|
) |
|
tex_for_build = wrapper |
|
# Ensure jobname is the original stem so minted cache dir matches |
|
extra_latex_opts = f'-jobname={tex.stem}' |
|
jobname = tex.stem |
|
except Exception: |
|
pass |
|
|
|
ok, status, html_path = try_tex4ht_html(tex_for_build, out_dir, env, math_output=math_output, shell_escape=shell_escape, engine=engine, extra_latex_opts=extra_latex_opts, jobname=jobname) |
|
if html_path is None: |
|
html_path = out_dir |
|
return ok, f'tex4ht:{status}', html_path |
|
|
|
|
|
def _extract_title(html_file: Path) -> str: |
|
try: |
|
data = html_file.read_text(encoding='utf-8', errors='ignore') |
|
except Exception: |
|
return html_file.stem |
|
# Try HTML <title> |
|
import re |
|
m = re.search(r"<title>(.*?)</title>", data, flags=re.IGNORECASE | re.DOTALL) |
|
if m: |
|
t = re.sub(r"\s+", " ", m.group(1)).strip() |
|
if t: |
|
return t |
|
# Try first h1/h2 |
|
m = re.search(r"<h[12][^>]*>(.*?)</h[12]>", data, flags=re.IGNORECASE | re.DOTALL) |
|
if m: |
|
t = re.sub(r"<[^>]+>", " ", m.group(1)) |
|
t = re.sub(r"\s+", " ", t).strip() |
|
if t: |
|
return t |
|
return html_file.stem |
|
|
|
|
|
def _write_catalog(dst_root: Path, items: List[Tuple[Path, bool, str, Path]]) -> int: |
|
"""Write docs_index.json at dst_root with entries for successful conversions.""" |
|
catalog = [] |
|
for tex, ok, summary, html_path in items: |
|
if not ok: |
|
continue |
|
if not html_path.exists() or html_path.is_dir(): |
|
continue |
|
rel = html_path.relative_to(dst_root) |
|
title = _extract_title(html_path) |
|
catalog.append({ |
|
'title': title, |
|
'path': str(rel).replace('\\', '/'), |
|
'source': str(tex), |
|
}) |
|
(dst_root / 'docs_index.json').write_text(json.dumps(catalog, ensure_ascii=False, indent=2), encoding='utf-8') |
|
return len(catalog) |
|
|
|
|
|
def _write_viewer(dst_root: Path) -> None: |
|
html = """<!DOCTYPE html> |
|
<html lang=\"en\"> |
|
<head> |
|
<meta charset=\"utf-8\" /> |
|
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" /> |
|
<title>Docs Viewer</title> |
|
<style> |
|
html, body { height: 100%; margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; overflow: hidden; } |
|
.layout { display: grid; grid-template-columns: 320px 1fr; height: 100vh; height: 100dvh; } |
|
.sidebar { border-right: 1px solid #ddd; display: flex; flex-direction: column; min-width:0; overflow: hidden; } |
|
.search { padding: 12px; border-bottom: 1px solid #eee; } |
|
.search input { width: 100%; padding: 8px 10px; font-size: 14px; border: 1px solid #ccc; border-radius: 6px; } |
|
.list { overflow: auto; padding: 8px; } |
|
.group { border-radius:8px; margin-bottom:8px; border:1px solid #e5e7eb; } |
|
.group summary { list-style:none; cursor:pointer; padding:6px 8px; font-weight:600; display:flex; align-items:center; gap:8px; } |
|
.group summary::-webkit-details-marker{ display:none; } |
|
.group[open] summary { background:#f3f5f7; border-bottom:1px solid #eee; } |
|
.group-items { padding:6px; } |
|
.item { padding: 6px 8px; border-radius: 6px; cursor: pointer; line-height: 1.2; } |
|
.item:hover { background: #f3f5f7; } |
|
.item.active { background: #e6f0ff; } |
|
.item small { display:block; color:#666; } |
|
.sections { margin: 6px 0 8px 8px; display:none; } |
|
.item.active + .sections { display:block; } |
|
.section-link { display:block; padding:4px 8px; margin:2px 0; border-radius:6px; background:#f8fafc; color:#0f172a; text-decoration:none; font-size:12px; } |
|
.section-link.active { background:#dbeafe; color:#0f172a; box-shadow: inset 0 0 0 1px #bfdbfe; } |
|
.section-link:hover { background:#eef2ff; } |
|
.lvl1 { font-weight:600; } |
|
.lvl2 { padding-left: 12px; } |
|
.lvl3 { padding-left: 20px; } |
|
.lvl4 { padding-left: 28px; } |
|
.main { display:flex; flex-direction:column; height:100%; min-height:0; overflow: hidden; } |
|
.viewer { border: 0; width: 100%; height: auto; flex: 1 1 0; min-height: 0; display:block; } |
|
.topbar { display:flex; align-items:center; gap:8px; padding:8px 12px; border-bottom:1px solid #eee; } |
|
.topbar .title { font-weight:600; flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } |
|
.topbar a { font-size:13px; color:#0366d6; text-decoration:none; } |
|
</style> |
|
<script> |
|
async function loadCatalog() { |
|
const res = await fetch('docs_index.json'); |
|
const data = await res.json(); |
|
// sort by path then title |
|
data.sort((a,b)=> (a.path < b.path ? -1 : a.path > b.path ? 1 : a.title.localeCompare(b.title))); |
|
return data; |
|
} |
|
const tocCache = new Map(); |
|
async function loadTocFor(path) { |
|
if (tocCache.has(path)) return tocCache.get(path); |
|
try { |
|
const res = await fetch(path); |
|
const html = await res.text(); |
|
const parser = new DOMParser(); |
|
const doc = parser.parseFromString(html, 'text/html'); |
|
const hs = Array.from(doc.querySelectorAll('h1,h2,h3,h4,h5')); |
|
const items = []; |
|
for (const h of hs) { |
|
const level = parseInt(h.tagName.substring(1), 10) || 2; |
|
let id = h.id; |
|
if (!id) { |
|
const a = h.querySelector('a[id]'); |
|
if (a) id = a.id; |
|
} |
|
if (!id) { |
|
const prev = h.previousElementSibling; |
|
if (prev && prev.id) id = prev.id; |
|
} |
|
const text = (h.textContent || '').trim().replace(/\\s+/g,' '); |
|
if (id && text) items.push({ id, text, level }); |
|
} |
|
tocCache.set(path, items); |
|
return items; |
|
} catch (e) { |
|
tocCache.set(path, []); |
|
return []; |
|
} |
|
} |
|
|
|
// Global helpers for section flashing inside the iframe |
|
let pendingFlashId = null; |
|
function ensureIframeHighlightStyles(doc){ |
|
try { |
|
if (!doc) return; |
|
if (doc.getElementById('flash-highlight-style')) return; |
|
const style = doc.createElement('style'); |
|
style.id = 'flash-highlight-style'; |
|
style.textContent = `@keyframes flashBg{0%{background:#fff3bf;}50%{background:#ffe066;}100%{background:transparent;}} |
|
.flash-highlight{animation:flashBg 1.6s ease-in-out 1; outline:2px solid #f59e0b; outline-offset:2px;}`; |
|
(doc.head || doc.documentElement).appendChild(style); |
|
} catch {} |
|
} |
|
function flashSectionInIframe(id){ |
|
try { |
|
const frame = document.querySelector('iframe.viewer'); |
|
if (!frame) return; |
|
const doc = frame.contentDocument; |
|
if (!doc) return; |
|
ensureIframeHighlightStyles(doc); |
|
let el = doc.getElementById(id) || doc.querySelector(`[id="${id}"]`) || doc.querySelector(`a[name="${id}"]`); |
|
if (el && el.tagName && el.tagName.toLowerCase() === 'a' && el.parentElement) { |
|
el = el.parentElement; |
|
} |
|
if (!el) return; |
|
el.classList.remove('flash-highlight'); |
|
void el.offsetWidth; |
|
el.classList.add('flash-highlight'); |
|
try { el.scrollIntoView({behavior:'smooth', block:'start', inline:'nearest'}); } catch {} |
|
setTimeout(()=>{ try { el.classList.remove('flash-highlight'); } catch {} }, 1600); |
|
} catch {} |
|
} |
|
|
|
function renderList(data, container, onSelect) { |
|
container.innerHTML = ''; |
|
const groups = new Map(); |
|
for (const d of data) { |
|
const top = (d.path.split('/')||[''])[0] || 'Other'; |
|
if (!groups.has(top)) groups.set(top, []); |
|
groups.get(top).push(d); |
|
} |
|
const sortedGroups = Array.from(groups.entries()).sort((a,b)=> a[0].localeCompare(b[0])); |
|
for (const [name, docs] of sortedGroups) { |
|
const details = document.createElement('details'); |
|
details.className = 'group'; |
|
details.open = true; |
|
const summary = document.createElement('summary'); |
|
summary.textContent = name + ' (' + docs.length + ')'; |
|
details.appendChild(summary); |
|
const items = document.createElement('div'); |
|
items.className = 'group-items'; |
|
for (const doc of docs.sort((a,b)=> a.title.localeCompare(b.title))) { |
|
const div = document.createElement('div'); |
|
div.className = 'item'; |
|
div.dataset.path = doc.path; |
|
div.innerHTML = `<strong>${escapeHtml(doc.title)}</strong><small>${escapeHtml(doc.path)}</small>`; |
|
div.onclick = async () => { |
|
onSelect(doc, div); |
|
// Populate section links below the active item |
|
const sec = div.nextSibling && div.nextSibling.classList && div.nextSibling.classList.contains('sections') ? div.nextSibling : document.createElement('div'); |
|
sec.className = 'sections'; |
|
sec.innerHTML = '<div style="color:#475569;font-size:12px;padding:4px 8px;">Sections</div>'; |
|
const toc = await loadTocFor(doc.path); |
|
for (const t of toc.slice(0, 200)) { // guard excessive items |
|
const a = document.createElement('a'); |
|
a.className = 'section-link lvl' + Math.min(Math.max((t.level||2)-0,1),4); |
|
a.textContent = t.text; |
|
a.href = '#' + t.id; |
|
a.onclick = (ev) => { |
|
ev.preventDefault(); |
|
const iframe = document.querySelector('iframe.viewer'); |
|
// Preserve current doc path; update hash |
|
const base = doc.path.split('#')[0]; |
|
iframe.src = base + '#' + t.id; |
|
// Highlight selected section |
|
const sibs = Array.from(sec.querySelectorAll('.section-link')); |
|
sibs.forEach(el => el.classList.remove('active')); |
|
a.classList.add('active'); |
|
// Prepare/trigger flash in iframe |
|
pendingFlashId = t.id; |
|
setTimeout(() => flashSectionInIframe(t.id), 80); |
|
}; |
|
sec.appendChild(a); |
|
} |
|
if (div.nextSibling !== sec) { |
|
div.parentNode.insertBefore(sec, div.nextSibling); |
|
} |
|
}; |
|
items.appendChild(div); |
|
// Placeholder for sections below item |
|
const secPh = document.createElement('div'); |
|
secPh.className = 'sections'; |
|
items.appendChild(secPh); |
|
} |
|
details.appendChild(items); |
|
container.appendChild(details); |
|
} |
|
} |
|
function escapeHtml(s){ |
|
return s.replace(/[&<>"']/g, function(c){ |
|
return ({ |
|
'&': '&', |
|
'<': '<', |
|
'>': '>', |
|
'"': '"', |
|
"'": ''' |
|
})[c]; |
|
}); |
|
} |
|
window.addEventListener('DOMContentLoaded', async () => { |
|
const listEl = document.querySelector('.list'); |
|
const searchEl = document.querySelector('.search input'); |
|
const frame = document.querySelector('iframe.viewer'); |
|
const titleEl = document.querySelector('.topbar .title'); |
|
const openEl = document.querySelector('.topbar .open'); |
|
|
|
function ensureIframeHighlightStyles(doc){ |
|
try { |
|
if (!doc) return; |
|
if (doc.getElementById('flash-highlight-style')) return; |
|
const style = doc.createElement('style'); |
|
style.id = 'flash-highlight-style'; |
|
style.textContent = `@keyframes flashBg{0%{background:#fff3bf;}50%{background:#ffe066;}100%{background:transparent;}} |
|
.flash-highlight{animation:flashBg 1.6s ease-in-out 1; outline:2px solid #f59e0b; outline-offset:2px;}`; |
|
(doc.head || doc.documentElement).appendChild(style); |
|
} catch {} |
|
} |
|
|
|
function flashSectionInIframe(id){ |
|
try { |
|
const doc = frame.contentDocument; |
|
if (!doc) return; |
|
ensureIframeHighlightStyles(doc); |
|
let el = doc.getElementById(id) || doc.querySelector(`[id="${id}"]`) || doc.querySelector(`a[name="${id}"]`); |
|
if (el && el.tagName && el.tagName.toLowerCase() === 'a' && el.parentElement) { |
|
el = el.parentElement; |
|
} |
|
if (!el) return; |
|
el.classList.remove('flash-highlight'); |
|
void el.offsetWidth; // restart animation |
|
el.classList.add('flash-highlight'); |
|
try { el.scrollIntoView({behavior:'smooth', block:'start', inline:'nearest'}); } catch {} |
|
setTimeout(()=>{ try { el.classList.remove('flash-highlight'); } catch {} }, 1600); |
|
} catch {} |
|
} |
|
|
|
frame.addEventListener('load', () => { |
|
try { |
|
const hash = (frame.contentWindow && frame.contentWindow.location && frame.contentWindow.location.hash) ? frame.contentWindow.location.hash : ''; |
|
const id = pendingFlashId || (hash ? hash.substring(1) : ''); |
|
if (id) { |
|
setTimeout(() => flashSectionInIframe(id), 60); |
|
} |
|
pendingFlashId = null; |
|
} catch {} |
|
}); |
|
const all = await loadCatalog(); |
|
let filtered = all.slice(); |
|
let activeEl = null; |
|
const select = (doc, el) => { |
|
if (activeEl) activeEl.classList.remove('active'); |
|
activeEl = el; if (activeEl) activeEl.classList.add('active'); |
|
frame.src = doc.path; titleEl.textContent = doc.title; openEl.href = doc.path; |
|
}; |
|
const applyFilter = () => { |
|
const q = searchEl.value.toLowerCase().trim(); |
|
if (!q) { filtered = all.slice(); } |
|
else { |
|
filtered = all.filter(d => d.title.toLowerCase().includes(q) || d.path.toLowerCase().includes(q)); |
|
} |
|
renderList(filtered, listEl, select); |
|
}; |
|
searchEl.addEventListener('input', applyFilter); |
|
applyFilter(); |
|
if (filtered.length) { |
|
// Auto-select first doc |
|
const firstItem = listEl.querySelector('.item'); |
|
if (firstItem) firstItem.click(); |
|
} |
|
}); |
|
</script> |
|
</head> |
|
<body> |
|
<div class=\"layout\"> |
|
<div class=\"sidebar\"> |
|
<div class=\"search\"><input type=\"search\" placeholder=\"Search title or path...\" /><div class=\"count\"></div></div> |
|
<div class=\"list\"></div> |
|
</div> |
|
<div class=\"main\"> |
|
<div class=\"topbar\"><div class=\"title\">Select a document</div><a class=\"open-src\" target=\"_blank\" href=\"#\">Open source</a><a class=\"open\" target=\"_blank\" href=\"#\">Open doc</a><button class=\"theme\" type=\"button\">Theme: System</button></div> |
|
<iframe class=\"viewer\"></iframe> |
|
</div> |
|
</div> |
|
</body> |
|
</html> |
|
""" |
|
(dst_root / 'index.html').write_text(html, encoding='utf-8') |
|
|
|
|
|
def main(): |
|
parser = argparse.ArgumentParser(description='Convert LaTeX documents in a folder to HTML.') |
|
parser.add_argument('--src', default='tmp_Docs', help='Source directory with .tex files (default: tmp_Docs)') |
|
parser.add_argument('--dst', default='tmp_Docs_html', help='Output directory for HTML (default: tmp_Docs_html)') |
|
parser.add_argument('--all-tex', action='store_true', help='Attempt to convert every .tex file (not only those with \\documentclass)') |
|
parser.add_argument('--clean', action='store_true', help='Clean each output subfolder before converting') |
|
parser.add_argument('--math-output', choices=['mathml', 'mathjax'], default='mathjax', |
|
help="Math format for make4ht: 'mathml' (generate MathML; rendered via MathJax SVG) or 'mathjax' (keep TeX; rendered via MathJax SVG)") |
|
# Shell-escape is needed for packages like minted that spawn external tools |
|
parser.add_argument('--shell-escape', dest='shell_escape', action='store_true', help='Enable -shell-escape for LaTeX runs') |
|
parser.add_argument('--no-shell-escape', dest='shell_escape', action='store_false', help='Disable -shell-escape for LaTeX runs') |
|
parser.set_defaults(shell_escape=True) |
|
parser.add_argument('--engine', choices=['pdflatex','lualatex','xelatex'], default='pdflatex', help='LaTeX engine to use') |
|
parser.add_argument('--postbuild-fix-images', action='store_true', |
|
help='Run post-build image fixer to copy/normalize equation PNGs referenced by *_htmlwrap.html') |
|
args = parser.parse_args() |
|
|
|
src_root = Path(args.src).resolve() |
|
if not src_root.exists(): |
|
# Be a bit forgiving for case: try tmp_Docs/tmp_docs |
|
alt = Path('tmp_docs') |
|
if alt.exists(): |
|
src_root = alt.resolve() |
|
else: |
|
print(f"Source directory not found: {args.src}", file=sys.stderr) |
|
sys.exit(2) |
|
|
|
dst_root = Path(args.dst).resolve() |
|
dst_root.mkdir(parents=True, exist_ok=True) |
|
|
|
tex_files = sorted(src_root.rglob('*.tex')) |
|
if not tex_files: |
|
print('No .tex files found.', file=sys.stderr) |
|
sys.exit(1) |
|
|
|
print(f"Found {len(tex_files)} .tex files under {src_root}") |
|
|
|
converted = 0 |
|
failed = 0 |
|
skipped = 0 |
|
results = [] |
|
|
|
for tex in tex_files: |
|
is_main = detect_main_tex(tex) |
|
if not (args.all_tex or is_main): |
|
skipped += 1 |
|
continue |
|
|
|
print(f"Converting: {tex.relative_to(src_root)}") |
|
ok, summary, html_path = convert_one(tex, src_root, dst_root, clean=args.clean, math_output=args.math_output, shell_escape=args.shell_escape, engine=args.engine) |
|
results.append((tex, ok, summary, html_path)) |
|
if ok: |
|
converted += 1 |
|
print(f" ✓ Success via {summary}. Output: {html_path.relative_to(dst_root)}") |
|
else: |
|
failed += 1 |
|
print(f" ✗ Failed ({summary}). See build.log under {html_path.relative_to(dst_root)}") |
|
|
|
print('\nConversion summary:') |
|
print(f" Converted: {converted}") |
|
print(f" Failed: {failed}") |
|
print(f" Skipped: {skipped}") |
|
|
|
# Write viewer and catalog for easy navigation |
|
try: |
|
count = _write_catalog(dst_root, results) |
|
_write_viewer(dst_root) |
|
print(f"\nWrote catalog with {count} entries: {dst_root / 'docs_index.json'}") |
|
print(f"Open the viewer: {dst_root / 'index.html'}") |
|
except Exception as e: |
|
print(f"Warning: failed to write viewer/catalog: {e}", file=sys.stderr) |
|
|
|
# Optional: run post-build image fixer |
|
if args.postbuild_fix_images: |
|
try: |
|
repo_root = Path(__file__).resolve().parent |
|
fixer = repo_root / 'scripts' / 'post_build_fix_images.py' |
|
if fixer.exists(): |
|
print("\nRunning post-build image fixer...") |
|
subprocess.run([sys.executable, str(fixer)], check=False) |
|
else: |
|
print(f"post-build fixer not found at {fixer}") |
|
except Exception as e: |
|
print(f"Warning: post-build fixer failed: {e}", file=sys.stderr) |
|
|
|
# Provide a helpful exit code |
|
sys.exit(0 if failed == 0 else 1) |
|
|
|
|
|
if __name__ == '__main__': |
|
main() |