#!/usr/bin/env python3 """Build a single self-contained, GitBook-style PAGED mobile reader — offline-robust. Works even where JavaScript is disabled (iOS Files/Quick Look): pages are a readable scroll by default; JS (Safari, Documents app, etc.) upgrades to one-page-at-a-time. - Math: pandoc --mathml (native WebKit, no JS) - Mermaid: PRE-RENDERED to static SVG (no JS, no mermaid.js) — pass --svg-dir - TOC drawer: CSS checkbox hack (opens without JS); prev/next are static anchors - Images/CSS embedded (--embed-resources) -> one offline file """ import argparse, os, re, subprocess, sys, posixpath def esc(s): return s.replace("&","&").replace("<","<").replace(">",">") def escattr(s): return s.replace("&","&").replace('"',""").replace("<","<") def parse_summary(book_dir): items, seen = [], set() with open(os.path.join(book_dir, "SUMMARY.md"), encoding="utf-8") as f: for line in f: m = re.match(r'^##\s+(.+?)\s*$', line) if m: items.append(("part", m.group(1))); continue m = re.match(r'^(\s*)[-*]\s+\[(.*?)\]\(([^)]+?)\)', line) if m: indent, title, path = m.group(1), m.group(2).strip(), m.group(3).strip() if path.endswith(".md") and path not in seen and os.path.isfile(os.path.join(book_dir, path)): seen.add(path) items.append(("file", path, title, min(len(indent.replace("\t"," "))//2, 2))) return items def fix_inline_dollar(text): def repl(m): s, e = m.start(), m.end(); inner = m.group(1) if "\n" in inner: return m.group(0) ls = text.rfind("\n", 0, s) + 1 le = text.find("\n", e); le = len(text) if le < 0 else le if text[ls:s].strip() == "" and text[e:le].strip() == "": return m.group(0) return "$" + inner.strip() + "$" return re.sub(r'\$\$(.+?)\$\$', repl, text, flags=re.DOTALL) def process_file(text, reldir, mermaid_store, path_to_id): def grab(m): idx = len(mermaid_store); mermaid_store.append(m.group(1)) return f"\n\nMERMAIDZZ{idx}ZZ\n\n" text = re.sub(r'```mermaid[ \t]*\n(.*?)\n[ \t]*```', grab, text, flags=re.DOTALL) text = fix_inline_dollar(text) text = re.sub(r'\[!\[[^\]]*\]\(https?://[^)]*\)\]\([^)]*\)', '', text) text = re.sub(r'!\[[^\]]*\]\(https?://[^)]*\)', '', text) text = re.sub(r'^\s*\[\]\([^)]*\)\s*$', '', text, flags=re.M) def md_img(m): alt, url = m.group(1), m.group(2).strip() if url.startswith(("http://","https://","/","data:")): return m.group(0) return f"![{alt}]({posixpath.normpath(posixpath.join(reldir, url))})" text = re.sub(r'!\[([^\]]*)\]\(([^)]+)\)', md_img, text) def html_img(m): src = m.group(1) if src.startswith(("http://","https://","/","data:")): return m.group(0) return m.group(0).replace(f'src="{src}"', f'src="{posixpath.normpath(posixpath.join(reldir, src))}"') text = re.sub(r']*src="([^"]+)"[^>]*>', html_img, text) def md_link(m): label, target = m.group(1), m.group(2).strip() if "#" in target: target = target.split("#", 1)[0] if not target.endswith(".md"): return m.group(0) pid = path_to_id.get(posixpath.normpath(posixpath.join(reldir, target))) return f"[{label}](#{pid})" if pid else m.group(0) text = re.sub(r'(? $title$
$title$
$body$
''' def main(): ap = argparse.ArgumentParser() ap.add_argument("--book-dir", required=True) ap.add_argument("--title", required=True) ap.add_argument("--out", required=True) ap.add_argument("--svg-dir", required=True, help="dir with pre-rendered d-1.svg .. d-N.svg") a = ap.parse_args() book_dir = os.path.abspath(a.book_dir) items = parse_summary(book_dir) page_meta, path_to_id, pidc = [], {}, 0 for it in items: if it[0] == "file": _, path, title, lvl = it page_meta.append((f"p{pidc}", path, title, lvl)) path_to_id[posixpath.normpath(path)] = f"p{pidc}"; pidc += 1 id_to_title = {pi: ti for (pi, _, ti, _) in page_meta} mermaid_store, chunks, pi = [], [], 0 for it in items: if it[0] != "file": continue _, path, title, lvl = it with open(os.path.join(book_dir, path), encoding="utf-8") as f: txt = f.read() txt = process_file(txt, posixpath.dirname(path), mermaid_store, path_to_id) chunks.append(f'\n\nPGBKZZp{pi}ZZ\n\n{txt}\n\n'); pi += 1 combined = "\n".join(chunks) print(f" pages: {len(page_meta)}, mermaid blocks: {len(mermaid_store)}") # load pre-rendered SVGs, namespace ids to avoid collisions; fall back to source on miss svgs, missing = [], 0 for i in range(len(mermaid_store)): p = os.path.join(a.svg_dir, f"d-{i+1}.svg") if os.path.isfile(p) and os.path.getsize(p) > 0: s = open(p, encoding="utf-8").read() j = s.find(" 0 else s s = s.replace("my-svg", f"mmd{i}") svgs.append(s) else: missing += 1 svgs.append('
' + esc(mermaid_store[i]) + '
') if missing: print(f" WARNING: {missing}/{len(mermaid_store)} diagrams failed to render -> showing source as fallback") # sidebar TOC sb = ['
目录
'); sidebar_html = "".join(sb) tmp_md = os.path.join(book_dir, "_combined_tmp.md") tpl = "/tmp/_book_template.html"; out_tmp = "/tmp/_book_out.html" with open(tmp_md, "w", encoding="utf-8") as f: f.write(combined) with open(tpl, "w", encoding="utf-8") as f: f.write(TEMPLATE) cmd = ["pandoc", "_combined_tmp.md", "-f", "markdown", "-t", "html5", "--standalone", "--embed-resources", "--mathml", "--template", tpl, "--metadata", f"title={a.title}", "-o", out_tmp] print(" running pandoc ...") r = subprocess.run(cmd, cwd=book_dir, capture_output=True, text=True) if os.path.exists(tmp_md): os.remove(tmp_md) if r.returncode != 0: print("PANDOC FAILED:\n", r.stderr[:4000]); sys.exit(1) with open(out_tmp, encoding="utf-8") as f: html = f.read() # swap mermaid placeholders -> pre-rendered inline SVG (2nd pass catches any not in

) def mrepl(m): return f'

{svgs[int(m.group(1))]}
' html = re.sub(r'

\s*MERMAIDZZ(\d+)ZZ\s*

', mrepl, html) html = re.sub(r'MERMAIDZZ(\d+)ZZ', mrepl, html) # split
into pages, append static prev/next nav per page mm = re.search(r'(
)(.*?)(
)', html, flags=re.DOTALL) segs = re.split(r'

\s*PGBKZZ(p\d+)ZZ\s*

', mm.group(2)) def navhtml(i): out = [''); return "".join(out) pages_html = [] for k in range(1, len(segs), 2): pid, seg = segs[k], segs[k+1] idx = order_idx = [pm[0] for pm in page_meta].index(pid) pages_html.append(f'
{seg}{navhtml(idx)}
') new_main = mm.group(1) + '
' + "".join(pages_html) + '
' + mm.group(3) html = html[:mm.start()] + new_main + html[mm.end():] html = html.replace("", sidebar_html) leftover = len(re.findall(r'MERMAIDZZ\d+ZZ|PGBKZZ', html)) with open(a.out, "w", encoding="utf-8") as f: f.write(html) size = os.path.getsize(a.out) / 1048576 n_svg = html.count('class="diagram"'); n_math = html.count(': {n_math} | images: {n_img} | leftover: {leftover}") print(f" OUTPUT: {a.out} ({size:.2f} MB)") if __name__ == "__main__": main()