From 2bcc83dcf43110a56bd99d8d4c597c5ce7ebdada Mon Sep 17 00:00:00 2001 From: yeasy Date: Sun, 21 Jun 2026 10:14:47 +0800 Subject: [PATCH] ci: build and attach HTML reader to each release Renders Mermaid to SVG via the workflow's Chrome, pandoc --mathml, assembles a single self-contained responsive HTML reader, and attaches -.html to the GitHub Release next to the PDF. Non-blocking (continue-on-error). --- .github/workflows/auto-release.yml | 32 ++++ tools/build_html_reader.py | 265 +++++++++++++++++++++++++++++ tools/render_mermaid.py | 76 +++++++++ 3 files changed, 373 insertions(+) create mode 100644 tools/build_html_reader.py create mode 100644 tools/render_mermaid.py 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"![{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 = ['
目录
    ']; fi = 0 + for it in items: + if it[0] == "part": sb.append(f'
  • {esc(it[1])}
  • ') + else: + pidi, _, title, lvl = page_meta[fi]; fi += 1 + sb.append(f'') + sb.append('
'); 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() diff --git a/tools/render_mermaid.py b/tools/render_mermaid.py new file mode 100644 index 0000000..5124136 --- /dev/null +++ b/tools/render_mermaid.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Render all Mermaid diagrams in a GitBook-style book to SVG (for the mobile reader). + +Extracts ```mermaid blocks in SUMMARY.md order and renders them with mermaid-cli, +pointing puppeteer at a system Chrome (CHROME_BIN env or auto-detected). Chunked + +retried because a single large mmdc pass can crash headless Chrome. Diagrams that +still fail are simply left out — build_mobile_book.py shows their source as fallback. +Writes d-1.svg .. d-N.svg into --svg-out. Exits 0 even if some/all fail (non-fatal). +""" +import os, re, sys, glob, time, shutil, subprocess, argparse + +ap = argparse.ArgumentParser() +ap.add_argument("--book-dir", default=".") +ap.add_argument("--svg-out", required=True) +ap.add_argument("--chunk", type=int, default=25) +a = ap.parse_args() +BOOK, SVG = os.path.abspath(a.book_dir), os.path.abspath(a.svg_out) +shutil.rmtree(SVG, ignore_errors=True); os.makedirs(SVG) + +# extract mermaid sources in SUMMARY order (same order build_mobile_book.py uses) +srcs, seen = [], set() +sm = os.path.join(BOOK, "SUMMARY.md") +order = [] +for line in open(sm, encoding="utf-8"): + m = re.match(r'^\s*[-*]\s+\[.*?\]\(([^)]+?)\)', line) + if m and m.group(1).endswith(".md"): + p = m.group(1).strip() + if p not in seen and os.path.isfile(os.path.join(BOOK, p)): + seen.add(p); order.append(p) +for p in order: + txt = open(os.path.join(BOOK, p), encoding="utf-8").read() + for mm in re.finditer(r'```mermaid[ \t]*\n(.*?)\n[ \t]*```', txt, re.DOTALL): + srcs.append(mm.group(1)) +N = len(srcs) +print(f"mermaid diagrams found: {N}") +if N == 0: + sys.exit(0) + +chrome = os.environ.get("CHROME_BIN") or next( + (shutil.which(n) for n in ["google-chrome-stable","google-chrome","chromium-browser","chromium","chrome"] if shutil.which(n)), None) +if not chrome: + print("WARNING: no Chrome found -> all diagrams will fall back to source"); sys.exit(0) +print(f"using Chrome: {chrome}") +pptr = os.path.join(SVG, "_pptr.json") +open(pptr, "w").write('{"executablePath":"%s","args":["--no-sandbox","--disable-gpu","--disable-dev-shm-usage"]}' % chrome) +rc = os.path.join(SVG, "_rc.json"); open(rc, "w").write('{"theme":"default"}') +MMDC = shutil.which("mmdc") or "mmdc" + +def render(indices): + cm = os.path.join(SVG, "_chunk.md") + open(cm, "w", encoding="utf-8").write("\n".join("```mermaid\n"+srcs[i]+"\n```\n" for i in indices)) + subprocess.run(["pkill", "-f", "enable-automation"], capture_output=True) # only puppeteer Chrome + time.sleep(1) + subprocess.run([MMDC, "-i", cm, "-o", os.path.join(SVG, "_c.svg"), "-p", pptr, "-c", rc, "-b", "transparent"], + capture_output=True, text=True) + for j, i in enumerate(indices, 1): + sp = os.path.join(SVG, f"_c-{j}.svg") + if os.path.isfile(sp) and os.path.getsize(sp) > 0: + os.replace(sp, os.path.join(SVG, f"d-{i+1}.svg")) + for st in glob.glob(os.path.join(SVG, "_c-*.svg")): os.remove(st) + +def done(): return len([i for i in range(N) if os.path.isfile(os.path.join(SVG, f"d-{i+1}.svg"))]) + +for c in range((N + a.chunk - 1) // a.chunk): + s, e = c*a.chunk, min(c*a.chunk + a.chunk, N) + render(list(range(s, e))) + print(f" chunk {c+1}: {done()}/{N}", flush=True) +for att in range(4): + miss = [i for i in range(N) if not os.path.isfile(os.path.join(SVG, f"d-{i+1}.svg"))] + if not miss: break + print(f" retry {att+1}: {len(miss)} missing", flush=True) + for b in range(0, len(miss), 8): render(miss[b:b+8]) + +for f in glob.glob(os.path.join(SVG, "*.json")) + glob.glob(os.path.join(SVG, "_chunk.md")): + os.remove(f) +print(f"RENDERED {done()}/{N} diagrams")