Zread CLIhttps://zread.ai/cli

到时得自己输入一个apikey

这种情况其实不用“续跑”,而是要看 zread 的生成机制是覆盖式还是增量式。结论先说清楚:

👉 zread 没有官方“断点续跑”机制
👉 正确做法是:直接重新执行 generate(会复用已有结果,实际是“增量覆盖”)


✔ 标准恢复方式(推荐)

在你的项目目录直接执行:

zread generate

它会:

  • 读取 .zread/ 目录已有内容

  • 对缺失/未完成的部分重新生成

  • 已生成的文件通常会被复用或覆盖更新


✔ 如果你想更“安全恢复”(建议这样做)

1️⃣ 先看看当前状态

dir .zread

你通常会看到类似:

.zread/
 ├── README.md
 ├── structure.md
 ├── modules/
 ├── ...

👉 如果这些文件已经有一部分,说明不是白跑


2️⃣ 备份当前结果(防止覆盖)

xcopy .zread .zread_backup /E /I

3️⃣ 再重新生成

zread generate

✔ 如果生成过程中经常中断(你这种情况)

建议加两个策略:

✔ 方法1:只生成结构(更快)

zread generate --fast

或(取决版本):

zread generate --no-deep

👉 避免长时间分析代码


✔ 方法2:限制目录(非常关键)

比如你项目很大:

zread generate src/

👉 只分析核心代码目录


✔ 如果你想“真正断点续跑”(进阶)

zread本身不支持,但可以手动模拟:

思路:

  1. 删除未完成的大文件(比如突然中断的)

  2. 保留已完成模块

  3. 再跑 generate

例如:

del .zread\modules\某个半生成文件.md
zread generate

✔ 判断是否成功恢复

你只需要看:

zread browse

如果:

  • 文档完整

  • 模块结构正常

  • 没有明显缺失

👉 就已经恢复成功


✔ 实战建议(你这个项目很大,强烈建议)

结合你现在在做的(AI检测系统 + 多模块):

👉 最稳方案:

zread generate src compliance utils

👉 不要全盘扫描(容易卡/中断)


另外附加一个脚本把.zread\wiki\versions\2026-04-29-110653文件夹下的md和json打包成一个html的脚本

#!/usr/bin/env python3
"""Build a zread-like single-file HTML wiki from wiki.json and Markdown pages."""

from __future__ import annotations

import argparse
import html
import json
import os
import re
import sys
from pathlib import Path
from typing import Iterable


LEVEL_LABELS = {
    "Beginner": "初级",
    "Intermediate": "中级",
    "Advanced": "高级",
}


def read_text(path: Path) -> str:
    for encoding in ("utf-8-sig", "utf-8", "gb18030"):
        try:
            return path.read_text(encoding=encoding)
        except UnicodeDecodeError:
            continue
    return path.read_text(encoding="utf-8", errors="replace")


def slugify(value: str) -> str:
    value = re.sub(r"\s+", "-", value.strip().lower())
    value = re.sub(r"[^0-9a-zA-Z_\-\u4e00-\u9fff]+", "", value)
    return value or "section"


def inline_markdown(text: str) -> str:
    placeholders: list[str] = []

    def stash(value: str) -> str:
        placeholders.append(value)
        return f"\x00{len(placeholders) - 1}\x00"

    escaped = html.escape(text, quote=False)
    escaped = re.sub(
        r"!\[([^\]]*)\]\(([^)]+)\)",
        lambda m: stash(
            f'<img src="{html.escape(m.group(2), quote=True)}" '
            f'alt="{html.escape(m.group(1), quote=True)}">'
        ),
        escaped,
    )
    escaped = re.sub(
        r"\[([^\]]+)\]\(([^)]+)\)",
        lambda m: stash(
            f'<a href="{html.escape(m.group(2), quote=True)}">{m.group(1)}</a>'
        ),
        escaped,
    )
    escaped = re.sub(r"`([^`]+)`", lambda m: stash(f"<code>{m.group(1)}</code>"), escaped)
    escaped = re.sub(r"\*\*([^*]+)\*\*", r"<strong>\1</strong>", escaped)
    escaped = re.sub(r"(?<!\*)\*([^*\n]+)\*(?!\*)", r"<em>\1</em>", escaped)
    for i, value in enumerate(placeholders):
        escaped = escaped.replace(f"\x00{i}\x00", value)
    return escaped


def render_table(rows: list[str]) -> str:
    parsed = []
    for row in rows:
        cells = [inline_markdown(cell.strip()) for cell in row.strip().strip("|").split("|")]
        parsed.append(cells)

    has_separator = len(parsed) > 1 and all(
        re.fullmatch(r":?-{3,}:?", c.strip()) for c in parsed[1]
    )
    header = parsed[0]
    body = parsed[2:] if has_separator else parsed[1:]
    out = ["<div class=\"table-wrap\"><table>", "<thead><tr>"]
    out.extend(f"<th>{cell}</th>" for cell in header)
    out.append("</tr></thead>")
    if body:
        out.append("<tbody>")
        for row in body:
            out.append("<tr>")
            out.extend(f"<td>{cell}</td>" for cell in row)
            out.append("</tr>")
        out.append("</tbody>")
    out.append("</table></div>")
    return "\n".join(out)


def render_markdown(markdown_text: str) -> str:
    lines = markdown_text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
    out: list[str] = []
    paragraph: list[str] = []
    unordered: list[str] = []
    ordered: list[str] = []
    blockquote: list[str] = []
    table_rows: list[str] = []
    in_code = False
    code_lang = ""
    code_lines: list[str] = []

    def flush_paragraph() -> None:
        if paragraph:
            out.append(f"<p>{inline_markdown(' '.join(paragraph))}</p>")
            paragraph.clear()

    def flush_unordered() -> None:
        if unordered:
            out.append("<ul>")
            out.extend(f"<li>{item}</li>" for item in unordered)
            out.append("</ul>")
            unordered.clear()

    def flush_ordered() -> None:
        if ordered:
            out.append("<ol>")
            out.extend(f"<li>{item}</li>" for item in ordered)
            out.append("</ol>")
            ordered.clear()

    def flush_blockquote() -> None:
        if blockquote:
            out.append(f"<blockquote>{render_markdown(chr(10).join(blockquote))}</blockquote>")
            blockquote.clear()

    def flush_table() -> None:
        if table_rows:
            out.append(render_table(table_rows))
            table_rows.clear()

    def flush_blocks() -> None:
        flush_paragraph()
        flush_unordered()
        flush_ordered()
        flush_blockquote()
        flush_table()

    for raw_line in lines:
        line = raw_line.rstrip()
        fence = re.match(r"^```([A-Za-z0-9_-]*)\s*$", line)
        if fence:
            if in_code:
                code = "\n".join(code_lines)
                if code_lang.lower() == "mermaid":
                    out.append(f'<div class="mermaid">{html.escape(code)}</div>')
                else:
                    lang_class = f"language-{html.escape(code_lang)}" if code_lang else ""
                    out.append(f'<pre><code class="{lang_class}">{html.escape(code)}</code></pre>')
                in_code = False
                code_lang = ""
                code_lines.clear()
            else:
                flush_blocks()
                in_code = True
                code_lang = fence.group(1)
            continue

        if in_code:
            code_lines.append(raw_line)
            continue

        if not line.strip():
            flush_blocks()
            continue

        quote = re.match(r"^\s*>\s?(.*)$", line)
        if quote:
            flush_paragraph()
            flush_unordered()
            flush_ordered()
            flush_table()
            blockquote.append(quote.group(1))
            continue

        if re.match(r"^\s*\|.*\|\s*$", line):
            flush_paragraph()
            flush_unordered()
            flush_ordered()
            flush_blockquote()
            table_rows.append(line)
            continue

        heading = re.match(r"^(#{1,6})\s+(.+)$", line)
        if heading:
            flush_blocks()
            level = len(heading.group(1))
            title = heading.group(2).strip()
            out.append(f'<h{level} id="{slugify(title)}">{inline_markdown(title)}</h{level}>')
            continue

        if re.match(r"^\s*---+\s*$", line):
            flush_blocks()
            out.append("<hr>")
            continue

        item = re.match(r"^\s*[-*+]\s+(.+)$", line)
        if item:
            flush_paragraph()
            flush_ordered()
            flush_blockquote()
            flush_table()
            unordered.append(inline_markdown(item.group(1).strip()))
            continue

        numbered = re.match(r"^\s*\d+[.)]\s+(.+)$", line)
        if numbered:
            flush_paragraph()
            flush_unordered()
            flush_blockquote()
            flush_table()
            ordered.append(inline_markdown(numbered.group(1).strip()))
            continue

        flush_unordered()
        flush_ordered()
        flush_blockquote()
        flush_table()
        paragraph.append(line.strip())

    flush_blocks()
    if in_code:
        out.append(f"<pre><code>{html.escape(chr(10).join(code_lines))}</code></pre>")
    return "\n".join(out)


def load_pages(base_dir: Path) -> tuple[dict, list[dict]]:
    wiki_path = base_dir / "wiki.json"
    wiki = json.loads(read_text(wiki_path))
    pages = wiki.get("pages", [])
    return wiki, pages


def page_payload(base_dir: Path, page: dict, index: int) -> dict:
    filename = page.get("file") or ""
    path = base_dir / filename
    title = page.get("title") or Path(filename).stem or f"Page {index + 1}"
    slug = page.get("slug") or slugify(title)
    if path.exists():
        content = render_markdown(read_text(path))
    else:
        content = f'<p class="missing">File not found: {html.escape(filename)}</p>'
    level = page.get("level") or ""
    return {
        "index": index,
        "slug": slug,
        "title": title,
        "file": filename,
        "section": page.get("section") or "Ungrouped",
        "group": page.get("group") or "",
        "level": level,
        "levelLabel": LEVEL_LABELS.get(level, level),
        "content": content,
    }


def nav_html(pages: Iterable[dict]) -> str:
    current_section = None
    current_group = None
    parts: list[str] = []

    def close_group() -> None:
        nonlocal current_group
        if current_group is not None:
            parts.append("</div></div>")
            current_group = None

    def close_section() -> None:
        nonlocal current_section
        if current_section is not None:
            close_group()
            parts.append("</div>")
            current_section = None

    def page_link(page: dict, in_group: bool) -> str:
        meta_bits = [page["levelLabel"]] if in_group else [x for x in (page["group"], page["levelLabel"]) if x]
        meta = " / ".join(meta_bits)
        search_text = " ".join([page["title"], page["section"], page["group"], page["level"], page["levelLabel"], page["file"]])
        class_name = "nav-item in-group" if in_group else "nav-item"
        return (
            '<a class="{class_name}" href="#{slug}" data-index="{index}" data-search="{search}" title="{title_attr}">'
            '<span class="nav-title">{title}</span>{meta}</a>'.format(
                class_name=class_name,
                slug=html.escape(page["slug"], quote=True),
                index=page["index"],
                search=html.escape(search_text.lower(), quote=True),
                title_attr=html.escape(page["title"], quote=True),
                title=html.escape(page["title"]),
                meta=f'<span class="nav-meta">{html.escape(meta)}</span>' if meta else "",
            )
        )

    for page in pages:
        section = page["section"]
        if section != current_section:
            close_section()
            parts.append(f'<div class="nav-section"><div class="nav-heading">{html.escape(section)}</div>')
            current_section = section
        group = page["group"]
        if group:
            if group != current_group:
                close_group()
                group_id = f"group-{slugify(section)}-{slugify(group)}"
                group_search = " ".join([section, group]).lower()
                parts.append(
                    '<div class="nav-group" data-search="{search}">'
                    '<button type="button" class="nav-group-button" aria-expanded="true" aria-controls="{group_id}">'
                    '<span>{group}</span>'
                    '<svg class="nav-group-icon" xmlns="http://www.w3.org/2000/svg" width="14" height="14" '
                    'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" '
                    'stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg>'
                    '</button><div id="{group_id}" class="nav-group-items">'.format(
                        search=html.escape(group_search, quote=True),
                        group_id=html.escape(group_id, quote=True),
                        group=html.escape(group),
                    )
                )
                current_group = group
            parts.append(page_link(page, in_group=True))
        else:
            close_group()
            parts.append(page_link(page, in_group=False))
    close_section()
    return "\n".join(parts)


def build_html(base_dir: Path) -> str:
    wiki, raw_pages = load_pages(base_dir)
    pages = [page_payload(base_dir, page, index) for index, page in enumerate(raw_pages)]
    wiki_id = wiki.get("id", "")
    title = f"Wiki {wiki_id}".strip()
    generated_at = wiki.get("generated_at", "")
    payload = json.dumps(pages, ensure_ascii=False).replace("</", "<\\/")
    nav = nav_html(pages)

    return f"""<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>{html.escape(title)}</title>
  <style>
    :root {{
      --bg: #f7f8fa;
      --panel: #ffffff;
      --text: #1f2328;
      --muted: #6b7280;
      --muted-2: #8a94a6;
      --border: #e5e7eb;
      --soft: #f1f5f9;
      --accent: #2563eb;
      --accent-soft: #e8f0ff;
      --code-bg: #0f172a;
      --code-text: #e5edf7;
      --shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
    }}
    * {{ box-sizing: border-box; }}
    html {{ scroll-behavior: smooth; }}
    body {{
      margin: 0;
      background: var(--bg);
      color: var(--text);
      font: 15px/1.75 -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", Arial, sans-serif;
    }}
    a {{ color: var(--accent); text-decoration: none; }}
    a:hover {{ text-decoration: underline; }}
    .app {{ display: grid; grid-template-columns: 304px minmax(0, 1fr); min-height: 100vh; }}
    .sidebar {{
      position: sticky;
      top: 0;
      height: 100vh;
      overflow: auto;
      background: var(--panel);
      border-right: 1px solid var(--border);
      padding: 18px 14px;
    }}
    .brand {{ padding: 4px 6px 16px; border-bottom: 1px solid var(--border); margin-bottom: 14px; }}
    .brand-title {{ margin: 0; font-size: 18px; line-height: 1.25; font-weight: 700; }}
    .brand-subtitle {{ margin: 6px 0 0; color: var(--muted); font-size: 12px; line-height: 1.45; }}
    .search {{
      position: sticky;
      top: 0;
      background: var(--panel);
      padding-bottom: 12px;
      z-index: 2;
    }}
    #search {{
      width: 100%;
      height: 38px;
      border: 1px solid var(--border);
      border-radius: 6px;
      padding: 0 11px;
      color: var(--text);
      background: #fbfcfe;
      outline: none;
      font: inherit;
      line-height: 38px;
    }}
    #search:focus {{ border-color: var(--accent); box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12); }}
    .nav-section {{ margin-bottom: 14px; }}
    .nav-heading {{
      padding: 10px 8px 6px;
      color: var(--muted-2);
      font-size: 12px;
      font-weight: 700;
      letter-spacing: 0;
    }}
    .nav-group {{ margin: 4px 0; }}
    .nav-group-button {{
      display: flex;
      width: 100%;
      align-items: center;
      gap: 8px;
      border: 0;
      border-radius: 8px;
      background: transparent;
      color: #374151;
      cursor: pointer;
      font: inherit;
      font-size: 14px;
      line-height: 1.35;
      padding: 6px 8px 6px 16px;
      text-align: left;
      user-select: none;
    }}
    .nav-group-button:focus {{ outline: none; }}
    .nav-group-button:focus-visible {{ box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.18); }}
    .nav-group-button:hover {{ background: rgba(217, 217, 217, 0.18); }}
    .nav-group-button span {{ flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }}
    .nav-group-icon {{
      flex: 0 0 auto;
      color: #888;
      transform: rotate(90deg);
      transition: transform 150ms ease;
    }}
    .nav-group.collapsed .nav-group-icon {{ transform: rotate(0deg); }}
    .nav-group.collapsed .nav-group-items {{ display: none; }}
    .nav-group-items {{ padding: 4px 0 0; }}
    .nav-item {{
      display: block;
      border-radius: 6px;
      padding: 7px 9px;
      color: #374151;
      border: 1px solid transparent;
    }}
    .nav-item.in-group {{ padding-left: 24px; padding-right: 16px; }}
    .nav-item:hover {{ background: var(--soft); text-decoration: none; }}
    .nav-item.active {{
      background: var(--accent-soft);
      border-color: #c9dcff;
      color: #1649b5;
    }}
    .nav-title {{ display: block; font-size: 14px; line-height: 1.35; }}
    .nav-meta {{ display: block; margin-top: 2px; color: var(--muted); font-size: 12px; line-height: 1.35; }}
    .main {{ min-width: 0; }}
    .topbar {{
      position: sticky;
      top: 0;
      z-index: 5;
      height: 56px;
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 16px;
      background: rgba(247, 248, 250, 0.92);
      border-bottom: 1px solid var(--border);
      padding: 0 30px;
      backdrop-filter: blur(10px);
    }}
    .crumb {{ color: var(--muted); font-size: 13px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }}
    .top-actions {{ display: flex; gap: 8px; flex: 0 0 auto; }}
    .button {{
      display: inline-flex;
      align-items: center;
      justify-content: center;
      min-width: 76px;
      height: 32px;
      border: 1px solid var(--border);
      border-radius: 6px;
      background: var(--panel);
      color: #374151;
      font: inherit;
      font-size: 13px;
      cursor: pointer;
    }}
    .button:hover {{ border-color: #cbd5e1; background: #f8fafc; text-decoration: none; }}
    .button:disabled {{ color: #a2aab8; cursor: not-allowed; background: #f8fafc; }}
    .content-wrap {{ max-width: 980px; margin: 0 auto; padding: 34px 34px 64px; }}
    .doc {{
      background: var(--panel);
      border: 1px solid var(--border);
      border-radius: 8px;
      box-shadow: var(--shadow);
      padding: 38px 46px 44px;
      min-height: calc(100vh - 150px);
      overflow-wrap: anywhere;
    }}
    .doc-header {{ border-bottom: 1px solid var(--border); margin-bottom: 26px; padding-bottom: 20px; }}
    .tags {{ display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 10px; }}
    .tag {{
      border: 1px solid #d7deea;
      background: #f6f8fb;
      color: #536071;
      border-radius: 999px;
      padding: 2px 9px;
      font-size: 12px;
      line-height: 1.6;
    }}
    .doc h1 {{ margin: 0 0 7px; font-size: 32px; line-height: 1.28; letter-spacing: 0; }}
    .source {{ color: var(--muted); font-size: 13px; }}
    .markdown h1, .markdown h2, .markdown h3, .markdown h4, .markdown h5, .markdown h6 {{
      line-height: 1.35;
      margin: 1.7em 0 0.7em;
      scroll-margin-top: 76px;
    }}
    .markdown h1 {{ font-size: 28px; }}
    .markdown h2 {{ font-size: 23px; border-bottom: 1px solid var(--border); padding-bottom: 0.25em; }}
    .markdown h3 {{ font-size: 19px; }}
    .markdown h4 {{ font-size: 16px; }}
    .markdown p {{ margin: 0.8em 0; }}
    .markdown ul, .markdown ol {{ padding-left: 1.45em; margin: 0.75em 0; }}
    .markdown li {{ margin: 0.25em 0; }}
    .markdown hr {{ border: 0; border-top: 1px solid var(--border); margin: 28px 0; }}
    .markdown blockquote {{
      margin: 16px 0;
      border-left: 4px solid #d9e2ef;
      background: #f8fafc;
      color: #4b5563;
      padding: 10px 16px;
    }}
    .markdown code {{
      background: #eef2f7;
      border-radius: 4px;
      padding: 2px 5px;
      font-family: Consolas, "SFMono-Regular", "Liberation Mono", monospace;
      font-size: 0.92em;
    }}
    .markdown pre {{
      background: var(--code-bg);
      color: var(--code-text);
      border-radius: 8px;
      overflow: auto;
      padding: 16px 18px;
      line-height: 1.55;
      margin: 16px 0;
    }}
    .markdown pre code {{ background: transparent; padding: 0; color: inherit; }}
    .table-wrap {{ overflow: auto; margin: 16px 0; border: 1px solid var(--border); border-radius: 8px; }}
    table {{ width: 100%; border-collapse: collapse; font-size: 14px; }}
    th, td {{ border-bottom: 1px solid var(--border); border-right: 1px solid var(--border); padding: 9px 11px; vertical-align: top; }}
    th:last-child, td:last-child {{ border-right: 0; }}
    tr:last-child td {{ border-bottom: 0; }}
    th {{ background: #f4f7fb; text-align: left; font-weight: 650; }}
    img {{ max-width: 100%; height: auto; border-radius: 6px; }}
    .mermaid {{
      margin: 18px 0;
      padding: 16px;
      border: 1px solid var(--border);
      border-radius: 8px;
      background: #ffffff;
      overflow: auto;
    }}
    .pager {{
      display: flex;
      justify-content: space-between;
      gap: 12px;
      margin-top: 18px;
    }}
    .pager a {{
      flex: 1;
      min-height: 48px;
      border: 1px solid var(--border);
      border-radius: 8px;
      background: var(--panel);
      padding: 9px 12px;
      color: var(--text);
      box-shadow: var(--shadow);
    }}
    .pager a:last-child {{ text-align: right; }}
    .pager span {{ display: block; color: var(--muted); font-size: 12px; }}
    .pager strong {{ display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }}
    .hidden {{ display: none !important; }}
    .empty {{ padding: 18px 8px; color: var(--muted); font-size: 13px; }}
    .missing {{ color: #b42318; }}
    @media (max-width: 860px) {{
      .app {{ display: block; }}
      .sidebar {{ position: relative; height: auto; max-height: 48vh; border-right: 0; border-bottom: 1px solid var(--border); }}
      .topbar {{ padding: 0 14px; }}
      .button {{ min-width: 62px; }}
      .content-wrap {{ padding: 18px 12px 40px; }}
      .doc {{ padding: 26px 18px 32px; }}
      .doc h1 {{ font-size: 26px; }}
    }}
  </style>
</head>
<body>
  <div class="app">
    <aside class="sidebar">
      <div class="brand">
        <h1 class="brand-title">{html.escape(title)}</h1>
        <p class="brand-subtitle">{len(pages)} pages · {html.escape(generated_at)}</p>
      </div>
      <div class="search"><input id="search" type="search" placeholder="Search pages"></div>
      <nav id="nav">{nav}</nav>
      <div id="empty" class="empty hidden">No matching pages</div>
    </aside>
    <section class="main">
      <header class="topbar">
        <div id="crumb" class="crumb"></div>
        <div class="top-actions">
          <button id="prevTop" class="button" type="button">Prev</button>
          <button id="nextTop" class="button" type="button">Next</button>
        </div>
      </header>
      <div class="content-wrap">
        <article id="doc" class="doc"></article>
        <div id="pager" class="pager"></div>
      </div>
    </section>
  </div>
  <script id="pages-data" type="application/json">{payload}</script>
  <script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
  <script>
    const pages = JSON.parse(document.getElementById("pages-data").textContent);
    const doc = document.getElementById("doc");
    const crumb = document.getElementById("crumb");
    const pager = document.getElementById("pager");
    const links = [...document.querySelectorAll(".nav-item")];
    const groups = [...document.querySelectorAll(".nav-group")];
    const sections = [...document.querySelectorAll(".nav-section")];
    const search = document.getElementById("search");
    const empty = document.getElementById("empty");
    const prevTop = document.getElementById("prevTop");
    const nextTop = document.getElementById("nextTop");

    function pageUrl(page) {{
      return "#" + encodeURIComponent(page.slug);
    }}

    function currentIndex() {{
      const raw = decodeURIComponent(location.hash.replace(/^#/, ""));
      const found = pages.findIndex(page => page.slug === raw);
      return found >= 0 ? found : 0;
    }}

    function tags(page) {{
      return [page.section, page.group, page.levelLabel || page.level]
        .filter(Boolean)
        .map(value => `<span class="tag">${{escapeHtml(value)}}</span>`)
        .join("");
    }}

    function escapeHtml(value) {{
      return String(value).replace(/[&<>"']/g, ch => ({{
        "&": "&amp;",
        "<": "&lt;",
        ">": "&gt;",
        '"': "&quot;",
        "'": "&#39;"
      }}[ch]));
    }}

    function renderMermaid() {{
      if (!window.mermaid) return;
      mermaid.initialize({{ startOnLoad: false, theme: "default", securityLevel: "loose" }});
      document.querySelectorAll(".mermaid").forEach((node, index) => {{
        node.removeAttribute("data-processed");
        node.id = "mermaid-" + Date.now() + "-" + index;
      }});
      mermaid.run({{ querySelector: ".mermaid" }}).catch(() => {{}});
    }}

    function render(index) {{
      const page = pages[index] || pages[0];
      if (!page) return;
      document.title = page.title + " - {html.escape(title)}";
      crumb.textContent = [page.section, page.group, page.title].filter(Boolean).join(" / ");
      doc.innerHTML = `
        <header class="doc-header">
          <div class="tags">${{tags(page)}}</div>
          <h1>${{escapeHtml(page.title)}}</h1>
          <a class="source" href="${{escapeHtml(page.file)}}">${{escapeHtml(page.file)}}</a>
        </header>
        <div class="markdown">${{page.content}}</div>
      `;
      links.forEach(link => link.classList.toggle("active", Number(link.dataset.index) === index));
      prevTop.disabled = index <= 0;
      nextTop.disabled = index >= pages.length - 1;
      const prev = pages[index - 1];
      const next = pages[index + 1];
      pager.innerHTML = `
        ${{prev ? `<a href="${{pageUrl(prev)}}"><span>Previous</span><strong>${{escapeHtml(prev.title)}}</strong></a>` : "<span></span>"}}
        ${{next ? `<a href="${{pageUrl(next)}}"><span>Next</span><strong>${{escapeHtml(next.title)}}</strong></a>` : "<span></span>"}}
      `;
      window.scrollTo({{ top: 0, behavior: "instant" }});
      renderMermaid();
    }}

    function go(delta) {{
      const target = currentIndex() + delta;
      if (target >= 0 && target < pages.length) location.hash = pages[target].slug;
    }}

    prevTop.addEventListener("click", () => go(-1));
    nextTop.addEventListener("click", () => go(1));
    window.addEventListener("hashchange", () => render(currentIndex()));

    document.querySelectorAll(".nav-group-button").forEach(button => {{
      button.addEventListener("click", () => {{
        const group = button.closest(".nav-group");
        const collapsed = group.classList.toggle("collapsed");
        button.setAttribute("aria-expanded", String(!collapsed));
      }});
    }});

    search.addEventListener("input", () => {{
      const q = search.value.trim().toLowerCase();
      let visible = 0;
      links.forEach(link => {{
        const group = link.closest(".nav-group");
        const groupHit = group && group.dataset.search.includes(q);
        const hit = !q || link.dataset.search.includes(q) || groupHit;
        link.classList.toggle("hidden", !hit);
        if (hit) visible += 1;
      }});
      groups.forEach(group => {{
        const childHit = [...group.querySelectorAll(".nav-item")].some(link => !link.classList.contains("hidden"));
        group.classList.toggle("hidden", !childHit);
        if (q && childHit) {{
          group.classList.remove("collapsed");
          group.querySelector(".nav-group-button").setAttribute("aria-expanded", "true");
        }}
      }});
      sections.forEach(section => {{
        const childHit = [...section.querySelectorAll(".nav-item")].some(link => !link.classList.contains("hidden"));
        section.classList.toggle("hidden", !childHit);
      }});
      empty.classList.toggle("hidden", visible > 0);
    }});

    if (!location.hash && pages[0]) {{
      history.replaceState(null, "", "#" + pages[0].slug);
    }}
    render(currentIndex());
  </script>
</body>
</html>
"""


def open_output_file(path: Path) -> None:
    try:
        os.startfile(path)  # type: ignore[attr-defined]
    except Exception as exc:
        print(f"Open failed: {exc}")


def wait_for_exit() -> None:
    print("Press any key to exit...")
    if os.name == "nt":
        try:
            import msvcrt

            msvcrt.getch()
            return
        except Exception:
            pass
    try:
        input()
    except EOFError:
        pass


def main() -> None:
    parser = argparse.ArgumentParser(description="Build a zread-like HTML wiki.")
    parser.add_argument("directory", nargs="?", default=".", help="Wiki directory, default: current directory")
    parser.add_argument("-o", "--output", default="wiki.html", help="Output HTML file name, default: wiki.html")
    parser.add_argument("--no-open", action="store_true", help="Do not open the generated HTML file")
    parser.add_argument("--no-pause", action="store_true", help="Do not wait for a key before exiting")
    args = parser.parse_args()

    base_dir = Path(args.directory).resolve()
    output = Path(args.output)
    if not output.is_absolute():
        output = base_dir / output

    output.write_text(build_html(base_dir), encoding="utf-8", newline="\n")
    print(f"Generated HTML: {output}")
    if not args.no_open:
        open_output_file(output)
    if not args.no_pause and sys.stdin.isatty():
        wait_for_exit()


if __name__ == "__main__":
    main()

运行 python build_wiki_html.py

就会生成一个wiki.html脚本就不用zread browser来浏览了

python -m PyInstaller --onefile --name build_wiki_html .\build_wiki_html.py
 

生成结果在: 
dist\build_wiki_html.exe
 
可以直接运行并生成 HTML: 
.\dist\build_wiki_html.exe . -o wiki.html

注意:这个 exe 是 Windows 64 位的,适合在没有 Python 环境的 Windows 机器上运行。运行时把它放到包含 `wiki.json` 和 `.md` 文件的目录里,或者像上面那样传入目录参数。

再上一个能生成word,pdf ,html三个同时的脚本,因为生成docx比较慢要加参数
build_wiki_html.exe . -o wiki.html --docx

#!/usr/bin/env python3
"""Build a zread-like single-file HTML wiki from wiki.json and Markdown pages."""

from __future__ import annotations

import argparse
import html
import json
import os
import re
import shutil
import struct
import subprocess
import sys
import tempfile
import zipfile
from pathlib import Path
from typing import Iterable


LEVEL_LABELS = {
    "Beginner": "初级",
    "Intermediate": "中级",
    "Advanced": "高级",
}


def read_text(path: Path) -> str:
    for encoding in ("utf-8-sig", "utf-8", "gb18030"):
        try:
            return path.read_text(encoding=encoding)
        except UnicodeDecodeError:
            continue
    return path.read_text(encoding="utf-8", errors="replace")


def slugify(value: str) -> str:
    value = re.sub(r"\s+", "-", value.strip().lower())
    value = re.sub(r"[^0-9a-zA-Z_\-\u4e00-\u9fff]+", "", value)
    return value or "section"


def inline_markdown(text: str) -> str:
    placeholders: list[str] = []

    def stash(value: str) -> str:
        placeholders.append(value)
        return f"\x00{len(placeholders) - 1}\x00"

    escaped = html.escape(text, quote=False)
    escaped = re.sub(
        r"!\[([^\]]*)\]\(([^)]+)\)",
        lambda m: stash(
            f'<img src="{html.escape(m.group(2), quote=True)}" '
            f'alt="{html.escape(m.group(1), quote=True)}">'
        ),
        escaped,
    )
    escaped = re.sub(
        r"\[([^\]]+)\]\(([^)]+)\)",
        lambda m: stash(
            f'<a href="{html.escape(m.group(2), quote=True)}">{m.group(1)}</a>'
        ),
        escaped,
    )
    escaped = re.sub(r"`([^`]+)`", lambda m: stash(f"<code>{m.group(1)}</code>"), escaped)
    escaped = re.sub(r"\*\*([^*]+)\*\*", r"<strong>\1</strong>", escaped)
    escaped = re.sub(r"(?<!\*)\*([^*\n]+)\*(?!\*)", r"<em>\1</em>", escaped)
    for i, value in enumerate(placeholders):
        escaped = escaped.replace(f"\x00{i}\x00", value)
    return escaped


def render_table(rows: list[str]) -> str:
    parsed = []
    for row in rows:
        cells = [inline_markdown(cell.strip()) for cell in row.strip().strip("|").split("|")]
        parsed.append(cells)

    has_separator = len(parsed) > 1 and all(
        re.fullmatch(r":?-{3,}:?", c.strip()) for c in parsed[1]
    )
    header = parsed[0]
    body = parsed[2:] if has_separator else parsed[1:]
    out = ["<div class=\"table-wrap\"><table>", "<thead><tr>"]
    out.extend(f"<th>{cell}</th>" for cell in header)
    out.append("</tr></thead>")
    if body:
        out.append("<tbody>")
        for row in body:
            out.append("<tr>")
            out.extend(f"<td>{cell}</td>" for cell in row)
            out.append("</tr>")
        out.append("</tbody>")
    out.append("</table></div>")
    return "\n".join(out)


def render_markdown(markdown_text: str) -> str:
    lines = markdown_text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
    out: list[str] = []
    paragraph: list[str] = []
    unordered: list[str] = []
    ordered: list[str] = []
    blockquote: list[str] = []
    table_rows: list[str] = []
    in_code = False
    code_lang = ""
    code_lines: list[str] = []

    def flush_paragraph() -> None:
        if paragraph:
            out.append(f"<p>{inline_markdown(' '.join(paragraph))}</p>")
            paragraph.clear()

    def flush_unordered() -> None:
        if unordered:
            out.append("<ul>")
            out.extend(f"<li>{item}</li>" for item in unordered)
            out.append("</ul>")
            unordered.clear()

    def flush_ordered() -> None:
        if ordered:
            out.append("<ol>")
            out.extend(f"<li>{item}</li>" for item in ordered)
            out.append("</ol>")
            ordered.clear()

    def flush_blockquote() -> None:
        if blockquote:
            out.append(f"<blockquote>{render_markdown(chr(10).join(blockquote))}</blockquote>")
            blockquote.clear()

    def flush_table() -> None:
        if table_rows:
            out.append(render_table(table_rows))
            table_rows.clear()

    def flush_blocks() -> None:
        flush_paragraph()
        flush_unordered()
        flush_ordered()
        flush_blockquote()
        flush_table()

    for raw_line in lines:
        line = raw_line.rstrip()
        fence = re.match(r"^```([A-Za-z0-9_-]*)\s*$", line)
        if fence:
            if in_code:
                code = "\n".join(code_lines)
                if code_lang.lower() == "mermaid":
                    out.append(f'<div class="mermaid">{html.escape(code)}</div>')
                else:
                    lang_class = f"language-{html.escape(code_lang)}" if code_lang else ""
                    out.append(f'<pre><code class="{lang_class}">{html.escape(code)}</code></pre>')
                in_code = False
                code_lang = ""
                code_lines.clear()
            else:
                flush_blocks()
                in_code = True
                code_lang = fence.group(1)
            continue

        if in_code:
            code_lines.append(raw_line)
            continue

        if not line.strip():
            flush_blocks()
            continue

        quote = re.match(r"^\s*>\s?(.*)$", line)
        if quote:
            flush_paragraph()
            flush_unordered()
            flush_ordered()
            flush_table()
            blockquote.append(quote.group(1))
            continue

        if re.match(r"^\s*\|.*\|\s*$", line):
            flush_paragraph()
            flush_unordered()
            flush_ordered()
            flush_blockquote()
            table_rows.append(line)
            continue

        heading = re.match(r"^(#{1,6})\s+(.+)$", line)
        if heading:
            flush_blocks()
            level = len(heading.group(1))
            title = heading.group(2).strip()
            out.append(f'<h{level} id="{slugify(title)}">{inline_markdown(title)}</h{level}>')
            continue

        if re.match(r"^\s*---+\s*$", line):
            flush_blocks()
            out.append("<hr>")
            continue

        item = re.match(r"^\s*[-*+]\s+(.+)$", line)
        if item:
            flush_paragraph()
            flush_ordered()
            flush_blockquote()
            flush_table()
            unordered.append(inline_markdown(item.group(1).strip()))
            continue

        numbered = re.match(r"^\s*\d+[.)]\s+(.+)$", line)
        if numbered:
            flush_paragraph()
            flush_unordered()
            flush_blockquote()
            flush_table()
            ordered.append(inline_markdown(numbered.group(1).strip()))
            continue

        flush_unordered()
        flush_ordered()
        flush_blockquote()
        flush_table()
        paragraph.append(line.strip())

    flush_blocks()
    if in_code:
        out.append(f"<pre><code>{html.escape(chr(10).join(code_lines))}</code></pre>")
    return "\n".join(out)


def markdown_blocks(markdown_text: str) -> list[dict]:
    lines = markdown_text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
    blocks: list[dict] = []
    paragraph: list[str] = []
    list_items: list[str] = []
    table_rows: list[str] = []
    in_code = False
    code_lang = ""
    code_lines: list[str] = []

    def flush_paragraph() -> None:
        if paragraph:
            blocks.append({"type": "paragraph", "text": " ".join(paragraph)})
            paragraph.clear()

    def flush_list() -> None:
        if list_items:
            blocks.append({"type": "list", "items": list_items.copy()})
            list_items.clear()

    def flush_table() -> None:
        if table_rows:
            rows = [[cell.strip() for cell in row.strip().strip("|").split("|")] for row in table_rows]
            if len(rows) > 1 and all(re.fullmatch(r":?-{3,}:?", cell) for cell in rows[1]):
                rows.pop(1)
            blocks.append({"type": "table", "rows": rows})
            table_rows.clear()

    def flush_blocks() -> None:
        flush_paragraph()
        flush_list()
        flush_table()

    for raw_line in lines:
        line = raw_line.rstrip()
        fence = re.match(r"^```([A-Za-z0-9_-]*)\s*$", line)
        if fence:
            if in_code:
                blocks.append({"type": "code", "lang": code_lang, "text": "\n".join(code_lines)})
                in_code = False
                code_lang = ""
                code_lines.clear()
            else:
                flush_blocks()
                in_code = True
                code_lang = fence.group(1)
            continue
        if in_code:
            code_lines.append(raw_line)
            continue
        if not line.strip():
            flush_blocks()
            continue
        if re.match(r"^\s*\|.*\|\s*$", line):
            flush_paragraph()
            flush_list()
            table_rows.append(line)
            continue
        heading = re.match(r"^(#{1,6})\s+(.+)$", line)
        if heading:
            flush_blocks()
            blocks.append(
                {
                    "type": "heading",
                    "level": min(len(heading.group(1)) + 1, 6),
                    "text": strip_inline_markdown(heading.group(2).strip()),
                }
            )
            continue
        item = re.match(r"^\s*(?:[-*+]|\d+[.)])\s+(.+)$", line)
        if item:
            flush_paragraph()
            flush_table()
            list_items.append(strip_inline_markdown(item.group(1).strip()))
            continue
        if re.match(r"^\s*---+\s*$", line):
            flush_blocks()
            continue
        flush_list()
        flush_table()
        paragraph.append(strip_inline_markdown(line.strip()))

    flush_blocks()
    if in_code:
        blocks.append({"type": "code", "lang": code_lang, "text": "\n".join(code_lines)})
    return blocks


def strip_inline_markdown(text: str) -> str:
    text = re.sub(r"!\[([^\]]*)\]\(([^)]+)\)", r"\1", text)
    text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"\1", text)
    text = re.sub(r"`([^`]+)`", r"\1", text)
    text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text)
    text = re.sub(r"(?<!\*)\*([^*\n]+)\*(?!\*)", r"\1", text)
    return text


def load_pages(base_dir: Path) -> tuple[dict, list[dict]]:
    wiki_path = base_dir / "wiki.json"
    wiki = json.loads(read_text(wiki_path))
    pages = wiki.get("pages", [])
    return wiki, pages


def page_payload(base_dir: Path, page: dict, index: int) -> dict:
    filename = page.get("file") or ""
    path = base_dir / filename
    title = page.get("title") or Path(filename).stem or f"Page {index + 1}"
    slug = page.get("slug") or slugify(title)
    if path.exists():
        content = render_markdown(read_text(path))
    else:
        content = f'<p class="missing">File not found: {html.escape(filename)}</p>'
    level = page.get("level") or ""
    return {
        "index": index,
        "slug": slug,
        "title": title,
        "file": filename,
        "section": page.get("section") or "Ungrouped",
        "group": page.get("group") or "",
        "level": level,
        "levelLabel": LEVEL_LABELS.get(level, level),
        "content": content,
    }


def nav_html(pages: Iterable[dict]) -> str:
    current_section = None
    current_group = None
    parts: list[str] = []

    def close_group() -> None:
        nonlocal current_group
        if current_group is not None:
            parts.append("</div></div>")
            current_group = None

    def close_section() -> None:
        nonlocal current_section
        if current_section is not None:
            close_group()
            parts.append("</div>")
            current_section = None

    def page_link(page: dict, in_group: bool) -> str:
        meta_bits = [page["levelLabel"]] if in_group else [x for x in (page["group"], page["levelLabel"]) if x]
        meta = " / ".join(meta_bits)
        search_text = " ".join([page["title"], page["section"], page["group"], page["level"], page["levelLabel"], page["file"]])
        class_name = "nav-item in-group" if in_group else "nav-item"
        return (
            '<a class="{class_name}" href="#{slug}" data-index="{index}" data-search="{search}" title="{title_attr}">'
            '<span class="nav-title">{title}</span>{meta}</a>'.format(
                class_name=class_name,
                slug=html.escape(page["slug"], quote=True),
                index=page["index"],
                search=html.escape(search_text.lower(), quote=True),
                title_attr=html.escape(page["title"], quote=True),
                title=html.escape(page["title"]),
                meta=f'<span class="nav-meta">{html.escape(meta)}</span>' if meta else "",
            )
        )

    for page in pages:
        section = page["section"]
        if section != current_section:
            close_section()
            parts.append(f'<div class="nav-section"><div class="nav-heading">{html.escape(section)}</div>')
            current_section = section
        group = page["group"]
        if group:
            if group != current_group:
                close_group()
                group_id = f"group-{slugify(section)}-{slugify(group)}"
                group_search = " ".join([section, group]).lower()
                parts.append(
                    '<div class="nav-group" data-search="{search}">'
                    '<button type="button" class="nav-group-button" aria-expanded="true" aria-controls="{group_id}">'
                    '<span>{group}</span>'
                    '<svg class="nav-group-icon" xmlns="http://www.w3.org/2000/svg" width="14" height="14" '
                    'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" '
                    'stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg>'
                    '</button><div id="{group_id}" class="nav-group-items">'.format(
                        search=html.escape(group_search, quote=True),
                        group_id=html.escape(group_id, quote=True),
                        group=html.escape(group),
                    )
                )
                current_group = group
            parts.append(page_link(page, in_group=True))
        else:
            close_group()
            parts.append(page_link(page, in_group=False))
    close_section()
    return "\n".join(parts)


def build_html(base_dir: Path) -> str:
    wiki, raw_pages = load_pages(base_dir)
    pages = [page_payload(base_dir, page, index) for index, page in enumerate(raw_pages)]
    wiki_id = wiki.get("id", "")
    title = f"Wiki {wiki_id}".strip()
    generated_at = wiki.get("generated_at", "")
    payload = json.dumps(pages, ensure_ascii=False).replace("</", "<\\/")
    nav = nav_html(pages)

    return f"""<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>{html.escape(title)}</title>
  <style>
    :root {{
      --bg: #f7f8fa;
      --panel: #ffffff;
      --text: #1f2328;
      --muted: #6b7280;
      --muted-2: #8a94a6;
      --border: #e5e7eb;
      --soft: #f1f5f9;
      --accent: #2563eb;
      --accent-soft: #e8f0ff;
      --code-bg: #0f172a;
      --code-text: #e5edf7;
      --shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
    }}
    * {{ box-sizing: border-box; }}
    html {{ scroll-behavior: smooth; }}
    body {{
      margin: 0;
      background: var(--bg);
      color: var(--text);
      font: 15px/1.75 -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", Arial, sans-serif;
    }}
    a {{ color: var(--accent); text-decoration: none; }}
    a:hover {{ text-decoration: underline; }}
    .app {{ display: grid; grid-template-columns: 304px minmax(0, 1fr); min-height: 100vh; }}
    .sidebar {{
      position: sticky;
      top: 0;
      height: 100vh;
      overflow: auto;
      background: var(--panel);
      border-right: 1px solid var(--border);
      padding: 18px 14px;
    }}
    .brand {{ padding: 4px 6px 16px; border-bottom: 1px solid var(--border); margin-bottom: 14px; }}
    .brand-title {{ margin: 0; font-size: 18px; line-height: 1.25; font-weight: 700; }}
    .brand-subtitle {{ margin: 6px 0 0; color: var(--muted); font-size: 12px; line-height: 1.45; }}
    .search {{
      position: sticky;
      top: 0;
      background: var(--panel);
      padding-bottom: 12px;
      z-index: 2;
    }}
    #search {{
      width: 100%;
      height: 38px;
      border: 1px solid var(--border);
      border-radius: 6px;
      padding: 0 11px;
      color: var(--text);
      background: #fbfcfe;
      outline: none;
      font: inherit;
      line-height: 38px;
    }}
    #search:focus {{ border-color: var(--accent); box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12); }}
    .nav-section {{ margin-bottom: 14px; }}
    .nav-heading {{
      padding: 10px 8px 6px;
      color: var(--muted-2);
      font-size: 12px;
      font-weight: 700;
      letter-spacing: 0;
    }}
    .nav-group {{ margin: 4px 0; }}
    .nav-group-button {{
      display: flex;
      width: 100%;
      align-items: center;
      gap: 8px;
      border: 0;
      border-radius: 8px;
      background: transparent;
      color: #374151;
      cursor: pointer;
      font: inherit;
      font-size: 14px;
      line-height: 1.35;
      padding: 6px 8px 6px 16px;
      text-align: left;
      user-select: none;
    }}
    .nav-group-button:focus {{ outline: none; }}
    .nav-group-button:focus-visible {{ box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.18); }}
    .nav-group-button:hover {{ background: rgba(217, 217, 217, 0.18); }}
    .nav-group-button span {{ flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }}
    .nav-group-icon {{
      flex: 0 0 auto;
      color: #888;
      transform: rotate(90deg);
      transition: transform 150ms ease;
    }}
    .nav-group.collapsed .nav-group-icon {{ transform: rotate(0deg); }}
    .nav-group.collapsed .nav-group-items {{ display: none; }}
    .nav-group-items {{ padding: 4px 0 0; }}
    .nav-item {{
      display: block;
      border-radius: 6px;
      padding: 7px 9px;
      color: #374151;
      border: 1px solid transparent;
    }}
    .nav-item.in-group {{ padding-left: 24px; padding-right: 16px; }}
    .nav-item:hover {{ background: var(--soft); text-decoration: none; }}
    .nav-item.active {{
      background: var(--accent-soft);
      border-color: #c9dcff;
      color: #1649b5;
    }}
    .nav-title {{ display: block; font-size: 14px; line-height: 1.35; }}
    .nav-meta {{ display: block; margin-top: 2px; color: var(--muted); font-size: 12px; line-height: 1.35; }}
    .main {{ min-width: 0; }}
    .topbar {{
      position: sticky;
      top: 0;
      z-index: 5;
      height: 56px;
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 16px;
      background: rgba(247, 248, 250, 0.92);
      border-bottom: 1px solid var(--border);
      padding: 0 30px;
      backdrop-filter: blur(10px);
    }}
    .crumb {{ color: var(--muted); font-size: 13px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }}
    .top-actions {{ display: flex; gap: 8px; flex: 0 0 auto; }}
    .button {{
      display: inline-flex;
      align-items: center;
      justify-content: center;
      min-width: 76px;
      height: 32px;
      border: 1px solid var(--border);
      border-radius: 6px;
      background: var(--panel);
      color: #374151;
      font: inherit;
      font-size: 13px;
      cursor: pointer;
    }}
    .button:hover {{ border-color: #cbd5e1; background: #f8fafc; text-decoration: none; }}
    .button:disabled {{ color: #a2aab8; cursor: not-allowed; background: #f8fafc; }}
    .content-wrap {{ max-width: 980px; margin: 0 auto; padding: 34px 34px 64px; }}
    .doc {{
      background: var(--panel);
      border: 1px solid var(--border);
      border-radius: 8px;
      box-shadow: var(--shadow);
      padding: 38px 46px 44px;
      min-height: calc(100vh - 150px);
      overflow-wrap: anywhere;
    }}
    .doc-header {{ border-bottom: 1px solid var(--border); margin-bottom: 26px; padding-bottom: 20px; }}
    .tags {{ display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 10px; }}
    .tag {{
      border: 1px solid #d7deea;
      background: #f6f8fb;
      color: #536071;
      border-radius: 999px;
      padding: 2px 9px;
      font-size: 12px;
      line-height: 1.6;
    }}
    .doc h1 {{ margin: 0 0 7px; font-size: 32px; line-height: 1.28; letter-spacing: 0; }}
    .source {{ color: var(--muted); font-size: 13px; }}
    .markdown h1, .markdown h2, .markdown h3, .markdown h4, .markdown h5, .markdown h6 {{
      line-height: 1.35;
      margin: 1.7em 0 0.7em;
      scroll-margin-top: 76px;
    }}
    .markdown h1 {{ font-size: 28px; }}
    .markdown h2 {{ font-size: 23px; border-bottom: 1px solid var(--border); padding-bottom: 0.25em; }}
    .markdown h3 {{ font-size: 19px; }}
    .markdown h4 {{ font-size: 16px; }}
    .markdown p {{ margin: 0.8em 0; }}
    .markdown ul, .markdown ol {{ padding-left: 1.45em; margin: 0.75em 0; }}
    .markdown li {{ margin: 0.25em 0; }}
    .markdown hr {{ border: 0; border-top: 1px solid var(--border); margin: 28px 0; }}
    .markdown blockquote {{
      margin: 16px 0;
      border-left: 4px solid #d9e2ef;
      background: #f8fafc;
      color: #4b5563;
      padding: 10px 16px;
    }}
    .markdown code {{
      background: #eef2f7;
      border-radius: 4px;
      padding: 2px 5px;
      font-family: Consolas, "SFMono-Regular", "Liberation Mono", monospace;
      font-size: 0.92em;
    }}
    .markdown pre {{
      background: var(--code-bg);
      color: var(--code-text);
      border-radius: 8px;
      overflow: auto;
      padding: 16px 18px;
      line-height: 1.55;
      margin: 16px 0;
    }}
    .markdown pre code {{ background: transparent; padding: 0; color: inherit; }}
    .table-wrap {{ overflow: auto; margin: 16px 0; border: 1px solid var(--border); border-radius: 8px; }}
    table {{ width: 100%; border-collapse: collapse; font-size: 14px; }}
    th, td {{ border-bottom: 1px solid var(--border); border-right: 1px solid var(--border); padding: 9px 11px; vertical-align: top; }}
    th:last-child, td:last-child {{ border-right: 0; }}
    tr:last-child td {{ border-bottom: 0; }}
    th {{ background: #f4f7fb; text-align: left; font-weight: 650; }}
    img {{ max-width: 100%; height: auto; border-radius: 6px; }}
    .mermaid {{
      margin: 18px 0;
      padding: 16px;
      border: 1px solid var(--border);
      border-radius: 8px;
      background: #ffffff;
      overflow: auto;
    }}
    .pager {{
      display: flex;
      justify-content: space-between;
      gap: 12px;
      margin-top: 18px;
    }}
    .pager a {{
      flex: 1;
      min-height: 48px;
      border: 1px solid var(--border);
      border-radius: 8px;
      background: var(--panel);
      padding: 9px 12px;
      color: var(--text);
      box-shadow: var(--shadow);
    }}
    .pager a:last-child {{ text-align: right; }}
    .pager span {{ display: block; color: var(--muted); font-size: 12px; }}
    .pager strong {{ display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }}
    .hidden {{ display: none !important; }}
    .empty {{ padding: 18px 8px; color: var(--muted); font-size: 13px; }}
    .missing {{ color: #b42318; }}
    body.print-mode {{ background: #ffffff; }}
    body.print-mode .sidebar,
    body.print-mode .topbar,
    body.print-mode .pager {{ display: none; }}
    body.print-mode .app {{ display: block; min-height: auto; }}
    body.print-mode .content-wrap {{ max-width: none; padding: 0; }}
    body.print-mode .doc {{
      border: 0;
      border-radius: 0;
      box-shadow: none;
      min-height: 0;
      padding: 0;
    }}
    body.print-mode .print-page {{
      break-after: page;
      page-break-after: always;
      padding: 22mm 18mm;
    }}
    body.print-mode .print-page:last-child {{
      break-after: auto;
      page-break-after: auto;
    }}
    body.print-mode .markdown pre,
    body.print-mode .table-wrap,
    body.print-mode .mermaid {{
      break-inside: avoid;
      page-break-inside: avoid;
    }}
    body.print-mode .pdf-toc {{
      padding: 22mm 18mm;
      break-after: page;
      page-break-after: always;
    }}
    body.print-mode .pdf-toc h1 {{
      margin: 0 0 6mm;
      font-size: 30px;
      line-height: 1.25;
    }}
    body.print-mode .pdf-toc-meta {{
      margin: 0 0 10mm;
      color: var(--muted);
      font-size: 13px;
    }}
    body.print-mode .pdf-toc-section {{
      margin: 7mm 0 3mm;
      color: #111827;
      font-size: 17px;
      font-weight: 700;
    }}
    body.print-mode .pdf-toc-group {{
      margin: 4mm 0 2mm 5mm;
      color: #374151;
      font-size: 14px;
      font-weight: 650;
    }}
    body.print-mode .pdf-toc-link {{
      display: block;
      margin: 1.8mm 0 1.8mm 10mm;
      color: var(--accent);
      font-size: 13px;
      text-decoration: none;
    }}
    body.print-mode .pdf-toc-link.no-group {{ margin-left: 5mm; }}
    body.print-mode .pdf-toc-level {{
      color: var(--muted);
      font-size: 12px;
    }}
    @page {{ size: A4; margin: 0; }}
    @media (max-width: 860px) {{
      .app {{ display: block; }}
      .sidebar {{ position: relative; height: auto; max-height: 48vh; border-right: 0; border-bottom: 1px solid var(--border); }}
      .topbar {{ padding: 0 14px; }}
      .button {{ min-width: 62px; }}
      .content-wrap {{ padding: 18px 12px 40px; }}
      .doc {{ padding: 26px 18px 32px; }}
      .doc h1 {{ font-size: 26px; }}
    }}
  </style>
</head>
<body>
  <div class="app">
    <aside class="sidebar">
      <div class="brand">
        <h1 class="brand-title">{html.escape(title)}</h1>
        <p class="brand-subtitle">{len(pages)} pages · {html.escape(generated_at)}</p>
      </div>
      <div class="search"><input id="search" type="search" placeholder="Search pages"></div>
      <nav id="nav">{nav}</nav>
      <div id="empty" class="empty hidden">No matching pages</div>
    </aside>
    <section class="main">
      <header class="topbar">
        <div id="crumb" class="crumb"></div>
        <div class="top-actions">
          <button id="prevTop" class="button" type="button">Prev</button>
          <button id="nextTop" class="button" type="button">Next</button>
        </div>
      </header>
      <div class="content-wrap">
        <article id="doc" class="doc"></article>
        <div id="pager" class="pager"></div>
      </div>
    </section>
  </div>
  <script id="pages-data" type="application/json">{payload}</script>
  <script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
  <script>
    const pages = JSON.parse(document.getElementById("pages-data").textContent);
    const doc = document.getElementById("doc");
    const crumb = document.getElementById("crumb");
    const pager = document.getElementById("pager");
    const links = [...document.querySelectorAll(".nav-item")];
    const groups = [...document.querySelectorAll(".nav-group")];
    const sections = [...document.querySelectorAll(".nav-section")];
    const search = document.getElementById("search");
    const empty = document.getElementById("empty");
    const prevTop = document.getElementById("prevTop");
    const nextTop = document.getElementById("nextTop");
    const isPrintMode = new URLSearchParams(location.search).has("print");

    function pageUrl(page) {{
      return "#" + encodeURIComponent(page.slug);
    }}

    function currentIndex() {{
      const raw = decodeURIComponent(location.hash.replace(/^#/, ""));
      const found = pages.findIndex(page => page.slug === raw);
      return found >= 0 ? found : 0;
    }}

    function tags(page) {{
      return [page.section, page.group, page.levelLabel || page.level]
        .filter(Boolean)
        .map(value => `<span class="tag">${{escapeHtml(value)}}</span>`)
        .join("");
    }}

    function escapeHtml(value) {{
      return String(value).replace(/[&<>"']/g, ch => ({{
        "&": "&amp;",
        "<": "&lt;",
        ">": "&gt;",
        '"': "&quot;",
        "'": "&#39;"
      }}[ch]));
    }}

    function renderMermaid() {{
      if (!window.mermaid) return;
      mermaid.initialize({{ startOnLoad: false, theme: "default", securityLevel: "loose" }});
      document.querySelectorAll(".mermaid").forEach((node, index) => {{
        node.removeAttribute("data-processed");
        node.id = "mermaid-" + Date.now() + "-" + index;
      }});
      mermaid.run({{ querySelector: ".mermaid" }}).catch(() => {{}});
    }}

    function render(index) {{
      const page = pages[index] || pages[0];
      if (!page) return;
      document.title = page.title + " - {html.escape(title)}";
      crumb.textContent = [page.section, page.group, page.title].filter(Boolean).join(" / ");
      doc.innerHTML = `
        <header class="doc-header">
          <div class="tags">${{tags(page)}}</div>
          <h1>${{escapeHtml(page.title)}}</h1>
          <a class="source" href="${{escapeHtml(page.file)}}">${{escapeHtml(page.file)}}</a>
        </header>
        <div class="markdown">${{page.content}}</div>
      `;
      links.forEach(link => link.classList.toggle("active", Number(link.dataset.index) === index));
      prevTop.disabled = index <= 0;
      nextTop.disabled = index >= pages.length - 1;
      const prev = pages[index - 1];
      const next = pages[index + 1];
      pager.innerHTML = `
        ${{prev ? `<a href="${{pageUrl(prev)}}"><span>Previous</span><strong>${{escapeHtml(prev.title)}}</strong></a>` : "<span></span>"}}
        ${{next ? `<a href="${{pageUrl(next)}}"><span>Next</span><strong>${{escapeHtml(next.title)}}</strong></a>` : "<span></span>"}}
      `;
      window.scrollTo({{ top: 0, behavior: "instant" }});
      renderMermaid();
    }}

    function renderPrintMode() {{
      document.body.classList.add("print-mode");
      document.title = "{html.escape(title)}";
      let currentSection = "";
      let currentGroup = "";
      const toc = [`<section class="pdf-toc"><h1>{html.escape(title)}</h1><p class="pdf-toc-meta">${{pages.length}} pages · {html.escape(generated_at)}</p>`];
      pages.forEach(page => {{
        if (page.section !== currentSection) {{
          currentSection = page.section;
          currentGroup = "";
          toc.push(`<div class="pdf-toc-section">${{escapeHtml(page.section)}}</div>`);
        }}
        if (page.group && page.group !== currentGroup) {{
          currentGroup = page.group;
          toc.push(`<div class="pdf-toc-group">${{escapeHtml(page.group)}}</div>`);
        }}
        const groupClass = page.group ? "" : " no-group";
        const level = page.levelLabel || page.level;
        toc.push(`<a class="pdf-toc-link${{groupClass}}" href="#print-${{escapeHtml(page.slug)}}">${{escapeHtml(page.title)}}${{level ? ` <span class="pdf-toc-level">(${{escapeHtml(level)}})</span>` : ""}}</a>`);
      }});
      toc.push("</section>");
      doc.innerHTML = toc.join("") + pages.map(page => `
        <section class="print-page" id="print-${{escapeHtml(page.slug)}}">
          <header class="doc-header">
            <div class="tags">${{tags(page)}}</div>
            <h1>${{escapeHtml(page.title)}}</h1>
            <a class="source" href="${{escapeHtml(page.file)}}">${{escapeHtml(page.file)}}</a>
          </header>
          <div class="markdown">${{page.content}}</div>
        </section>
      `).join("");
      renderMermaid();
    }}

    function go(delta) {{
      const target = currentIndex() + delta;
      if (target >= 0 && target < pages.length) location.hash = pages[target].slug;
    }}

    prevTop.addEventListener("click", () => go(-1));
    nextTop.addEventListener("click", () => go(1));
    window.addEventListener("hashchange", () => render(currentIndex()));

    document.querySelectorAll(".nav-group-button").forEach(button => {{
      button.addEventListener("click", () => {{
        const group = button.closest(".nav-group");
        const collapsed = group.classList.toggle("collapsed");
        button.setAttribute("aria-expanded", String(!collapsed));
      }});
    }});

    search.addEventListener("input", () => {{
      const q = search.value.trim().toLowerCase();
      let visible = 0;
      links.forEach(link => {{
        const group = link.closest(".nav-group");
        const groupHit = group && group.dataset.search.includes(q);
        const hit = !q || link.dataset.search.includes(q) || groupHit;
        link.classList.toggle("hidden", !hit);
        if (hit) visible += 1;
      }});
      groups.forEach(group => {{
        const childHit = [...group.querySelectorAll(".nav-item")].some(link => !link.classList.contains("hidden"));
        group.classList.toggle("hidden", !childHit);
        if (q && childHit) {{
          group.classList.remove("collapsed");
          group.querySelector(".nav-group-button").setAttribute("aria-expanded", "true");
        }}
      }});
      sections.forEach(section => {{
        const childHit = [...section.querySelectorAll(".nav-item")].some(link => !link.classList.contains("hidden"));
        section.classList.toggle("hidden", !childHit);
      }});
      empty.classList.toggle("hidden", visible > 0);
    }});

    if (isPrintMode) {{
      renderPrintMode();
    }} else if (!location.hash && pages[0]) {{
      history.replaceState(null, "", "#" + pages[0].slug);
      render(currentIndex());
    }} else {{
      render(currentIndex());
    }}
  </script>
</body>
</html>
"""


def open_output_file(path: Path) -> None:
    try:
        os.startfile(path)  # type: ignore[attr-defined]
    except Exception as exc:
        print(f"Open failed: {exc}")


def find_browser_for_pdf() -> str | None:
    candidates = [
        shutil.which("msedge"),
        shutil.which("chrome"),
        shutil.which("chromium"),
    ]
    if os.name == "nt":
        candidates.extend(
            [
                r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
                r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
                r"C:\Program Files\Google\Chrome\Application\chrome.exe",
                r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
            ]
        )
    for candidate in candidates:
        if candidate and Path(candidate).exists():
            return str(candidate)
    return None


def build_pdf(html_path: Path, pdf_path: Path) -> bool:
    browser = find_browser_for_pdf()
    if not browser:
        print("PDF skipped: Microsoft Edge or Google Chrome was not found.")
        return False

    pdf_path.parent.mkdir(parents=True, exist_ok=True)
    if pdf_path.exists():
        try:
            pdf_path.unlink()
        except OSError as exc:
            print(f"PDF failed: cannot replace existing file: {exc}")
            return False
    url = html_path.resolve().as_uri() + "?print=1"
    command = [
        browser,
        "--headless=new",
        "--disable-gpu",
        "--allow-file-access-from-files",
        "--run-all-compositor-stages-before-draw",
        "--virtual-time-budget=10000",
        f"--print-to-pdf={pdf_path}",
        url,
    ]
    try:
        result = subprocess.run(command, capture_output=True, timeout=60)
    except Exception as exc:
        print(f"PDF failed: {exc}")
        return False

    if result.returncode != 0 or not pdf_path.exists():
        detail_bytes = result.stderr or result.stdout
        detail = detail_bytes.decode("utf-8", errors="ignore").strip()
        print(f"PDF failed: {detail or 'browser print command failed'}")
        return False
    print(f"Generated PDF: {pdf_path}")
    return True


def x(text: object) -> str:
    return html.escape(str(text), quote=True)


def w_text(text: str) -> str:
    parts = str(text).split("\n")
    if not parts:
        return '<w:t xml:space="preserve"></w:t>'
    runs = [f'<w:t xml:space="preserve">{x(parts[0])}</w:t>']
    for part in parts[1:]:
        runs.append(f'<w:br/><w:t xml:space="preserve">{x(part)}</w:t>')
    return "".join(runs)


def w_run(text: str, bold: bool = False, color: str | None = None) -> str:
    props = []
    if bold:
        props.append("<w:b/>")
    if color:
        props.append(f'<w:color w:val="{color}"/>')
    rpr = f"<w:rPr>{''.join(props)}</w:rPr>" if props else ""
    return f"<w:r>{rpr}{w_text(text)}</w:r>"


def w_paragraph(
    text: str = "",
    style: str | None = None,
    runs: list[str] | None = None,
    bookmark: str | None = None,
) -> str:
    ppr = f'<w:pPr><w:pStyle w:val="{style}"/></w:pPr>' if style else ""
    body = "".join(runs) if runs is not None else w_run(text)
    if bookmark:
        bookmark_id = abs(hash(bookmark)) % 2000000000
        body = (
            f'<w:bookmarkStart w:id="{bookmark_id}" w:name="{x(bookmark)}"/>'
            f"{body}"
            f'<w:bookmarkEnd w:id="{bookmark_id}"/>'
        )
    return f"<w:p>{ppr}{body}</w:p>"


def w_hyperlink(anchor: str, text: str, suffix: str = "") -> str:
    return (
        f'<w:hyperlink w:anchor="{x(anchor)}" w:history="1">'
        f'{w_run(text, color="2563EB")}{w_run(suffix, color="6B7280")}'
        "</w:hyperlink>"
    )


def docx_anchor(slug: str) -> str:
    return "page_" + re.sub(r"[^0-9A-Za-z_]", "_", slug)


def w_table(rows: list[list[str]]) -> str:
    if not rows:
        return ""
    col_count = max(len(row) for row in rows)
    width = max(1200, int(9000 / max(1, col_count)))
    table = [
        "<w:tbl>",
        "<w:tblPr><w:tblStyle w:val=\"TableGrid\"/><w:tblW w:w=\"0\" w:type=\"auto\"/>"
        "<w:tblLook w:firstRow=\"1\" w:lastRow=\"0\" w:firstColumn=\"0\" w:lastColumn=\"0\" "
        "w:noHBand=\"0\" w:noVBand=\"1\"/></w:tblPr>",
        "<w:tblGrid>",
    ]
    table.extend(f'<w:gridCol w:w="{width}"/>' for _ in range(col_count))
    table.append("</w:tblGrid>")
    for row_index, row in enumerate(rows):
        table.append("<w:tr>")
        for cell in row + [""] * (col_count - len(row)):
            fill = '<w:shd w:fill="F4F7FB"/>' if row_index == 0 else ""
            table.append(
                "<w:tc><w:tcPr>"
                f'<w:tcW w:w="{width}" w:type="dxa"/>{fill}'
                "</w:tcPr>"
                f'{w_paragraph(strip_inline_markdown(cell), runs=[w_run(strip_inline_markdown(cell), bold=row_index == 0)])}'
                "</w:tc>"
            )
        table.append("</w:tr>")
    table.append("</w:tbl>")
    return "".join(table)


def png_size(path: Path) -> tuple[int, int]:
    data = path.read_bytes()
    if len(data) >= 24 and data[:8] == b"\x89PNG\r\n\x1a\n":
        return int.from_bytes(data[16:20], "big"), int.from_bytes(data[20:24], "big")
    return 1000, 600


def png_chunk(chunk_type: bytes, data: bytes) -> bytes:
    import binascii

    return (
        struct.pack(">I", len(data))
        + chunk_type
        + data
        + struct.pack(">I", binascii.crc32(chunk_type + data) & 0xFFFFFFFF)
    )


def crop_png_whitespace(path: Path, padding: int = 18) -> bool:
    import zlib

    data = path.read_bytes()
    if data[:8] != b"\x89PNG\r\n\x1a\n":
        return False

    offset = 8
    chunks: list[tuple[bytes, bytes]] = []
    while offset + 8 <= len(data):
        length = int.from_bytes(data[offset : offset + 4], "big")
        chunk_type = data[offset + 4 : offset + 8]
        chunk_data = data[offset + 8 : offset + 8 + length]
        chunks.append((chunk_type, chunk_data))
        offset += 12 + length
        if chunk_type == b"IEND":
            break

    ihdr = next((chunk for kind, chunk in chunks if kind == b"IHDR"), None)
    if not ihdr:
        return False
    width, height, bit_depth, color_type, compression, filter_method, interlace = struct.unpack(">IIBBBBB", ihdr)
    if bit_depth != 8 or color_type not in (2, 6) or compression != 0 or filter_method != 0 or interlace != 0:
        return False

    channels = 4 if color_type == 6 else 3
    stride = width * channels
    compressed = b"".join(chunk for kind, chunk in chunks if kind == b"IDAT")
    raw = zlib.decompress(compressed)
    rows: list[bytearray] = []
    previous = bytearray(stride)
    pos = 0
    for _ in range(height):
        filter_type = raw[pos]
        pos += 1
        scan = bytearray(raw[pos : pos + stride])
        pos += stride
        for i in range(stride):
            left = scan[i - channels] if i >= channels else 0
            up = previous[i]
            up_left = previous[i - channels] if i >= channels else 0
            if filter_type == 1:
                scan[i] = (scan[i] + left) & 0xFF
            elif filter_type == 2:
                scan[i] = (scan[i] + up) & 0xFF
            elif filter_type == 3:
                scan[i] = (scan[i] + ((left + up) // 2)) & 0xFF
            elif filter_type == 4:
                p = left + up - up_left
                pa = abs(p - left)
                pb = abs(p - up)
                pc = abs(p - up_left)
                predictor = left if pa <= pb and pa <= pc else up if pb <= pc else up_left
                scan[i] = (scan[i] + predictor) & 0xFF
            elif filter_type != 0:
                return False
        rows.append(scan)
        previous = scan

    min_x, min_y = width, height
    max_x, max_y = -1, -1
    for y, row in enumerate(rows):
        for x_pos in range(width):
            i = x_pos * channels
            r, g, b = row[i], row[i + 1], row[i + 2]
            alpha = row[i + 3] if channels == 4 else 255
            if alpha > 8 and (r < 245 or g < 245 or b < 245):
                min_x = min(min_x, x_pos)
                max_x = max(max_x, x_pos)
                min_y = min(min_y, y)
                max_y = max(max_y, y)

    if max_x < min_x or max_y < min_y:
        return False
    min_x = max(0, min_x - padding)
    min_y = max(0, min_y - padding)
    max_x = min(width - 1, max_x + padding)
    max_y = min(height - 1, max_y + padding)
    new_width = max_x - min_x + 1
    new_height = max_y - min_y + 1
    if new_width >= width * 0.98 and new_height >= height * 0.98:
        return False

    cropped = bytearray()
    for row in rows[min_y : max_y + 1]:
        cropped.append(0)
        cropped.extend(row[min_x * channels : (max_x + 1) * channels])

    new_ihdr = struct.pack(">IIBBBBB", new_width, new_height, bit_depth, color_type, compression, filter_method, interlace)
    output = bytearray(b"\x89PNG\r\n\x1a\n")
    output.extend(png_chunk(b"IHDR", new_ihdr))
    output.extend(png_chunk(b"IDAT", zlib.compress(bytes(cropped), level=9)))
    output.extend(png_chunk(b"IEND", b""))
    path.write_bytes(output)
    return True


def w_image(rid: str, name: str, width_px: int, height_px: int) -> str:
    max_width_emu = 6_250_000
    max_height_emu = 8_650_000
    emu_per_px = 9525
    cx = max(1, width_px * emu_per_px)
    cy = max(1, height_px * emu_per_px)
    scale = max_width_emu / cx
    cx = max_width_emu
    cy = max(1, int(cy * scale))
    if cy > max_height_emu:
        height_scale = max_height_emu / cy
        cy = max_height_emu
        cx = max(1, int(cx * height_scale))
    doc_pr_id = abs(hash(rid)) % 2_000_000_000
    drawing = f"""
<w:drawing>
  <wp:inline xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" distT="0" distB="0" distL="0" distR="0">
    <wp:extent cx="{cx}" cy="{cy}"/>
    <wp:effectExtent l="0" t="0" r="0" b="0"/>
    <wp:docPr id="{doc_pr_id}" name="{x(name)}"/>
    <wp:cNvGraphicFramePr>
      <a:graphicFrameLocks xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" noChangeAspect="1"/>
    </wp:cNvGraphicFramePr>
    <a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
      <a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture">
        <pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">
          <pic:nvPicPr><pic:cNvPr id="0" name="{x(name)}"/><pic:cNvPicPr/></pic:nvPicPr>
          <pic:blipFill>
            <a:blip xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:embed="{x(rid)}"/>
            <a:stretch><a:fillRect/></a:stretch>
          </pic:blipFill>
          <pic:spPr>
            <a:xfrm><a:off x="0" y="0"/><a:ext cx="{cx}" cy="{cy}"/></a:xfrm>
            <a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
          </pic:spPr>
        </pic:pic>
      </a:graphicData>
    </a:graphic>
  </wp:inline>
</w:drawing>"""
    return f"<w:p><w:r>{drawing}</w:r></w:p>"


def render_mermaid_png(diagram: str, output_path: Path) -> bool:
    browser = find_browser_for_pdf()
    if not browser:
        return False
    output_path.parent.mkdir(parents=True, exist_ok=True)
    html_path = output_path.with_suffix(".html")
    html_path.write_text(
        f"""<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <style>
    body {{ margin: 0; background: #ffffff; }}
    .wrap {{ display: inline-block; padding: 32px; background: #ffffff; }}
    .mermaid {{ font-family: "Microsoft YaHei", Arial, sans-serif; }}
    .mermaid svg {{ width: 1800px !important; max-width: none !important; height: auto !important; }}
  </style>
</head>
<body>
  <div class="wrap"><pre class="mermaid">{html.escape(diagram)}</pre></div>
  <script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
  <script>
    mermaid.initialize({{ startOnLoad: true, theme: "default", securityLevel: "loose" }});
  </script>
</body>
</html>""",
        encoding="utf-8",
    )
    command = [
        browser,
        "--headless=new",
        "--disable-gpu",
        "--allow-file-access-from-files",
        "--hide-scrollbars",
        "--run-all-compositor-stages-before-draw",
        "--virtual-time-budget=10000",
        "--window-size=1920,3000",
        f"--screenshot={output_path.resolve()}",
        html_path.resolve().as_uri(),
    ]
    try:
        subprocess.run(command, capture_output=True, timeout=60)
    except Exception:
        return False
    if output_path.exists() and output_path.stat().st_size > 0:
        crop_png_whitespace(output_path)
        return True
    return False


def docx_content_types() -> str:
    return """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
  <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
  <Default Extension="xml" ContentType="application/xml"/>
  <Default Extension="png" ContentType="image/png"/>
  <Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
  <Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>
</Types>"""


def docx_root_rels() -> str:
    return """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
</Relationships>"""


def docx_document_rels(image_rels: list[dict] | None = None) -> str:
    rels = []
    for rel in image_rels or []:
        rels.append(
            f'<Relationship Id="{x(rel["rid"])}" '
            'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" '
            f'Target="media/{x(rel["name"])}"/>'
        )
    return (
        '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
        '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
        + "".join(rels)
        + "</Relationships>"
    )


def docx_styles() -> str:
    return """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
  <w:docDefaults>
    <w:rPrDefault><w:rPr><w:rFonts w:ascii="Microsoft YaHei" w:eastAsia="Microsoft YaHei" w:hAnsi="Microsoft YaHei"/><w:sz w:val="21"/></w:rPr></w:rPrDefault>
    <w:pPrDefault><w:pPr><w:spacing w:after="120" w:line="300" w:lineRule="auto"/></w:pPr></w:pPrDefault>
  </w:docDefaults>
  <w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style>
  <w:style w:type="paragraph" w:styleId="Title"><w:name w:val="Title"/><w:pPr><w:spacing w:after="240"/></w:pPr><w:rPr><w:b/><w:color w:val="111827"/><w:sz w:val="36"/></w:rPr></w:style>
  <w:style w:type="paragraph" w:styleId="Subtitle"><w:name w:val="Subtitle"/><w:rPr><w:color w:val="6B7280"/><w:sz w:val="20"/></w:rPr></w:style>
  <w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:basedOn w:val="Normal"/><w:pPr><w:outlineLvl w:val="0"/><w:spacing w:before="360" w:after="160"/></w:pPr><w:rPr><w:b/><w:color w:val="111827"/><w:sz w:val="32"/></w:rPr></w:style>
  <w:style w:type="paragraph" w:styleId="Heading2"><w:name w:val="heading 2"/><w:basedOn w:val="Normal"/><w:pPr><w:outlineLvl w:val="1"/><w:spacing w:before="300" w:after="120"/></w:pPr><w:rPr><w:b/><w:color w:val="1F2937"/><w:sz w:val="26"/></w:rPr></w:style>
  <w:style w:type="paragraph" w:styleId="Heading3"><w:name w:val="heading 3"/><w:basedOn w:val="Normal"/><w:pPr><w:outlineLvl w:val="2"/><w:spacing w:before="240" w:after="100"/></w:pPr><w:rPr><w:b/><w:color w:val="374151"/><w:sz w:val="23"/></w:rPr></w:style>
  <w:style w:type="paragraph" w:styleId="ListParagraph"><w:name w:val="List Paragraph"/><w:basedOn w:val="Normal"/><w:pPr><w:ind w:left="420" w:hanging="220"/></w:pPr></w:style>
  <w:style w:type="paragraph" w:styleId="Code"><w:name w:val="Code"/><w:pPr><w:shd w:fill="F1F5F9"/><w:spacing w:before="120" w:after="120"/></w:pPr><w:rPr><w:rFonts w:ascii="Consolas" w:hAnsi="Consolas"/><w:sz w:val="18"/></w:rPr></w:style>
  <w:style w:type="table" w:styleId="TableGrid"><w:name w:val="Table Grid"/><w:tblPr><w:tblBorders><w:top w:val="single" w:sz="4" w:space="0" w:color="D1D5DB"/><w:left w:val="single" w:sz="4" w:space="0" w:color="D1D5DB"/><w:bottom w:val="single" w:sz="4" w:space="0" w:color="D1D5DB"/><w:right w:val="single" w:sz="4" w:space="0" w:color="D1D5DB"/><w:insideH w:val="single" w:sz="4" w:space="0" w:color="D1D5DB"/><w:insideV w:val="single" w:sz="4" w:space="0" w:color="D1D5DB"/></w:tblBorders></w:tblPr></w:style>
</w:styles>"""


def build_docx(base_dir: Path, docx_path: Path) -> bool:
    wiki, raw_pages = load_pages(base_dir)
    pages = []
    image_rels: list[dict] = []
    image_files: list[dict] = []
    temp_dir = Path(tempfile.mkdtemp(prefix="wiki_docx_"))
    mermaid_total = 0
    mermaid_done = 0
    for index, raw_page in enumerate(raw_pages):
        page = page_payload(base_dir, raw_page, index)
        path = base_dir / page["file"]
        page["blocks"] = markdown_blocks(read_text(path)) if path.exists() else []
        mermaid_total += sum(
            1
            for block in page["blocks"]
            if block.get("type") == "code" and block.get("lang", "").lower() == "mermaid"
        )
        pages.append(page)

    def show_docx_progress(current: int, total: int, label: str = "") -> None:
        if total <= 0:
            return
        width = 28
        filled = int(width * current / total)
        bar = "#" * filled + "-" * (width - filled)
        percent = int(100 * current / total)
        suffix = f" {label}" if label else ""
        print(f"\rRendering Mermaid diagrams [{bar}] {current}/{total} {percent:3d}%{suffix}", end="", flush=True)

    body: list[str] = [
        w_paragraph(f"Wiki {wiki.get('id', '')}".strip(), style="Title"),
        w_paragraph(f"{len(pages)} pages · {wiki.get('generated_at', '')}", style="Subtitle"),
        w_paragraph("目录索引", style="Heading1"),
    ]
    current_section = ""
    current_group = ""
    for page in pages:
        if page["section"] != current_section:
            current_section = page["section"]
            current_group = ""
            body.append(w_paragraph(current_section, style="Heading2"))
        if page["group"] and page["group"] != current_group:
            current_group = page["group"]
            body.append(w_paragraph(current_group, style="Heading3"))
        level = page["levelLabel"] or page["level"]
        suffix = f" ({level})" if level else ""
        body.append(w_paragraph(runs=[w_hyperlink(docx_anchor(page["slug"]), page["title"], suffix)]))

    body.append('<w:p><w:r><w:br w:type="page"/></w:r></w:p>')
    for page in pages:
        body.append(
            w_paragraph(
                page["title"],
                style="Heading1",
                bookmark=docx_anchor(page["slug"]),
            )
        )
        tags = " / ".join(x for x in (page["section"], page["group"], page["levelLabel"]) if x)
        body.append(w_paragraph(tags, style="Subtitle"))
        if page["file"]:
            body.append(w_paragraph(page["file"], style="Subtitle"))
        for block in page["blocks"]:
            block_type = block["type"]
            if block_type == "heading":
                body.append(w_paragraph(block["text"], style=f'Heading{min(block["level"], 3)}'))
            elif block_type == "paragraph":
                body.append(w_paragraph(block["text"]))
            elif block_type == "list":
                for item in block["items"]:
                    body.append(w_paragraph(f"• {item}", style="ListParagraph"))
            elif block_type == "code":
                if block.get("lang", "").lower() == "mermaid":
                    mermaid_done += 1
                    show_docx_progress(mermaid_done, mermaid_total, page["title"])
                    image_name = f"mermaid_{len(image_files) + 1}.png"
                    image_path = temp_dir / image_name
                    if render_mermaid_png(block["text"], image_path):
                        rid = f"rIdImage{len(image_rels) + 1}"
                        width, height = png_size(image_path)
                        image_rels.append({"rid": rid, "name": image_name})
                        image_files.append({"path": image_path, "name": image_name})
                        body.append(w_paragraph("流程图", style="Subtitle"))
                        body.append(w_image(rid, image_name, width, height))
                    else:
                        body.append(w_paragraph("Mermaid diagram source", style="Subtitle"))
                        body.append(w_paragraph(block["text"], style="Code"))
                else:
                    label = block.get("lang", "code") or "code"
                    body.append(w_paragraph(label, style="Subtitle"))
                    body.append(w_paragraph(block["text"], style="Code"))
            elif block_type == "table":
                body.append(w_table(block["rows"]))

    document_xml = f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
  <w:body>
    {''.join(body)}
    <w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1134" w:right="1134" w:bottom="1134" w:left="1134" w:header="720" w:footer="720" w:gutter="0"/></w:sectPr>
  </w:body>
</w:document>"""

    docx_path.parent.mkdir(parents=True, exist_ok=True)
    if docx_path.exists():
        try:
            docx_path.unlink()
        except OSError as exc:
            print(f"DOCX failed: cannot replace existing file: {exc}")
            return False
    with zipfile.ZipFile(docx_path, "w", compression=zipfile.ZIP_DEFLATED) as docx:
        docx.writestr("[Content_Types].xml", docx_content_types())
        docx.writestr("_rels/.rels", docx_root_rels())
        docx.writestr("word/document.xml", document_xml)
        docx.writestr("word/styles.xml", docx_styles())
        docx.writestr("word/_rels/document.xml.rels", docx_document_rels(image_rels))
        for image in image_files:
            docx.write(image["path"], f'word/media/{image["name"]}')
    if mermaid_total:
        print()
    print(f"Generated DOCX: {docx_path}")
    return True


def wait_for_exit() -> None:
    print("Press any key to exit...")
    if os.name == "nt":
        try:
            import msvcrt

            msvcrt.getch()
            return
        except Exception:
            pass
    try:
        input()
    except EOFError:
        pass


def main() -> None:
    parser = argparse.ArgumentParser(description="Build a zread-like HTML wiki.")
    parser.add_argument("directory", nargs="?", default=".", help="Wiki directory, default: current directory")
    parser.add_argument("-o", "--output", default="wiki.html", help="Output HTML file name, default: wiki.html")
    parser.add_argument("--pdf-output", default=None, help="Output PDF file name, default: same name as HTML with .pdf")
    parser.add_argument("--docx", action="store_true", help="Generate a DOCX file")
    parser.add_argument("--docx-output", default=None, help="Output DOCX file name, default: same name as HTML with .docx")
    parser.add_argument("--no-pdf", action="store_true", help="Do not generate a PDF file")
    parser.add_argument("--no-open", action="store_true", help="Do not open the generated HTML file")
    parser.add_argument("--no-pause", action="store_true", help="Do not wait for a key before exiting")
    args = parser.parse_args()

    base_dir = Path(args.directory).resolve()
    output = Path(args.output)
    if not output.is_absolute():
        output = base_dir / output

    output.write_text(build_html(base_dir), encoding="utf-8", newline="\n")
    print(f"Generated HTML: {output}")
    if not args.no_pdf:
        pdf_output = Path(args.pdf_output) if args.pdf_output else output.with_suffix(".pdf")
        if not pdf_output.is_absolute():
            pdf_output = base_dir / pdf_output
        build_pdf(output, pdf_output)
    if args.docx:
        docx_output = Path(args.docx_output) if args.docx_output else output.with_suffix(".docx")
        if not docx_output.is_absolute():
            docx_output = base_dir / docx_output
        build_docx(base_dir, docx_output)
    if not args.no_open:
        open_output_file(output)
    if not args.no_pause and sys.stdin.isatty():
        wait_for_exit()


if __name__ == "__main__":
    main()

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐