diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml
index 922eb91..98ebe01 100644
--- a/.github/workflows/auto-release.yml
+++ b/.github/workflows/auto-release.yml
@@ -22,6 +22,7 @@ jobs:
run: npm test
- name: Install Chromium and CJK fonts
+ id: setupchrome
uses: browser-actions/setup-chrome@v2
with:
chrome-version: stable
@@ -62,3 +63,34 @@ jobs:
with:
name: docker_practice-pdf
path: "docker_practice-*.pdf"
+
+ - name: Build HTML reader
+ id: htmlreader
+ continue-on-error: true
+ env:
+ CHROME_BIN: ${{ steps.setupchrome.outputs.chrome-path }}
+ run: |
+ # recent pandoc (apt's is too old for --embed-resources); + mermaid-cli using system Chrome
+ curl -fsSL https://github.com/jgm/pandoc/releases/download/3.5/pandoc-3.5-1-amd64.deb -o /tmp/pandoc.deb
+ sudo dpkg -i /tmp/pandoc.deb && pandoc --version | head -1
+ PUPPETEER_SKIP_DOWNLOAD=true npm install -g @mermaid-js/mermaid-cli@10
+ TITLE=$(python3 -c "import json,os;print((json.load(open('book.json')).get('title') if os.path.exists('book.json') else '') or '${{ github.event.repository.name }}')")
+ TAG=$(echo "${{ steps.tag.outputs.TAG_NAME || 'latest' }}" | sed 's#.*/##') # slash-free (handles non-tag dispatch)
+ python3 tools/render_mermaid.py --book-dir . --svg-out /tmp/mmsvg
+ python3 tools/build_html_reader.py --book-dir . --title "$TITLE" --svg-dir /tmp/mmsvg \
+ --out "${{ github.event.repository.name }}-${TAG}.html"
+ ls -lh ${{ github.event.repository.name }}-${TAG}.html
+
+ - name: Attach HTML to release
+ if: steps.htmlreader.outcome == 'success' && startsWith(github.ref, 'refs/tags/')
+ uses: softprops/action-gh-release@v2
+ with:
+ tag_name: ${{ steps.tag.outputs.TAG_NAME }}
+ files: "${{ github.event.repository.name }}-${{ steps.tag.outputs.TAG_NAME }}.html"
+
+ - name: Upload HTML as artifact
+ if: steps.htmlreader.outcome == 'success'
+ uses: actions/upload-artifact@v7
+ with:
+ name: html-edition
+ path: "${{ github.event.repository.name }}-*.html"
diff --git a/tools/build_html_reader.py b/tools/build_html_reader.py
new file mode 100644
index 0000000..5cb4da7
--- /dev/null
+++ b/tools/build_html_reader.py
@@ -0,0 +1,265 @@
+#!/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$
+
+
+
+
+
+
+
+
+$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("