#!/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")})"
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("