diff --git a/.gitignore b/.gitignore index af8fa4c..c753539 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,7 @@ # Build output -output/ \ No newline at end of file +output/ + +# Python +__pycache__/ +*.pyc +*.pyo diff --git a/CLAUDE.md b/CLAUDE.md index 7ec15de..e31286c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,10 +12,14 @@ Markdown 纯文本写作 + Git 版本控制 + Python 构建管道 → 自包含 project.yaml # 项目元数据与章节列表 chapters/ # Markdown 章节源文件(唯一编辑目标) assets/ # 图片资源 +data/ # 结构化数据源(CSV/YAML/JSON,通过 @import 引用) doc_builder/ # Python 构建工具 build.py # 构建入口 templates/ # HTML 模板 themes/ # CSS 样式 + renderers/ # 自定义渲染器(@import / 代码块渲染) + processors/ # 数据处理器(供渲染器复用) + checks/ # 检查脚本(构建时自动运行) output/ # 构建产物(gitignore) ``` @@ -31,7 +35,8 @@ output/ # 构建产物(gitignore) - **禁止在子章节文件中使用 H1**,确保拼接后层级正确 ### 图片规范 -- **一律使用项目根目录相对路径**: `![描述](./assets/xxx.png)` +- **从 chapters/ 引用项目根目录 assets/**: `![描述](../assets/xxx.png)` +- 此路径同时兼容标准 Markdown 预览和构建时的 base64 内嵌 - **禁止绝对路径**(尤其是 Windows 盘符路径如 `D:/code/...`) - 构建时自动内嵌为 base64,生成自包含 HTML @@ -40,10 +45,11 @@ output/ # 构建产物(gitignore) - 外部引用: `[文档名](path/to/doc.md)` ### 非标准 Markdown 扩展 -本项目采用 docs-as-code skill 规范的非标准扩展: -- `@import "data/file.csv"` — 数据注入(预留) -- `![描述](path){w=50%}` — 图片属性控制(预留) -- 自定义代码块渲染器(预留 `doc_builder/renderers/`) +本项目采用 docs-as-code skill 规范的非标准扩展(构建时生效,Markdown 预览中可忽略): +- `@import "../data/file.csv"` — 将数据文件或 Markdown 注入当前章节 +- `@import "../data/file.csv" using render_custom` — 使用 `doc_builder/renderers/render_custom.py` 渲染 +- `![描述](../assets/x.png){w=50%}` — 图片属性控制(预留) +- 自定义代码块渲染器:`doc_builder/renderers/render_.py` ## 构建流程 @@ -54,7 +60,7 @@ pip install -r requirements.txt # 构建 HTML python doc_builder/build.py -# 输出: output/读出子系统编程控制模型.html +# 输出: output/<标题>.html ``` ## 文件组织表 @@ -85,6 +91,10 @@ python doc_builder/build.py | 正文内容 | `chapters/*.md` | `python doc_builder/build.py` | | 章节顺序 | `project.yaml` → `chapters:` 列表 | 同上 | | 图片 | `assets/` | 同上(自动内嵌) | +| 结构化数据 | `data/*.csv` / `data/*.yaml` / `data/*.json` | 同上(通过 @import 注入) | +| 渲染器逻辑 | `doc_builder/renderers/*.py` | 同上(自动发现) | +| 数据处理逻辑 | `doc_builder/processors/*.py` | 同上(自动发现) | +| 检查规则 | `doc_builder/checks/*.py` | 同上(构建时自动运行) | | HTML 样式 | `doc_builder/themes/*.css` | 同上 | | HTML 模板 | `doc_builder/templates/report.html` | 同上 | @@ -92,5 +102,5 @@ python doc_builder/build.py 1. 本项目是 **硬件寄存器级编程手册**,包含大量位域表格和时序说明 2. 平台差异(FPGA vs ASIC)使用代码块标注 -3. 数学公式使用 `$...$`(行内)和 `$$...$$`(块级)LaTeX 语法 -4. 原有根目录下的 `*.md` 文件是 MPE 编辑器兼容的历史文件,编辑以 `chapters/` 为准 +3. 数学公式使用 `$...$`(行内)和 `$$...$$`(块级)LaTeX 语法,构建时自动保护公式不被 Markdown 转义破坏 +4. 编辑以 `chapters/` 下的文件为准,项目根目录无旧版 MPE 兼容文件 diff --git a/README.md b/README.md index 286728c..8216c5d 100644 --- a/README.md +++ b/README.md @@ -15,10 +15,14 @@ project.yaml # 项目配置(标题、作者、版本、章节列表) chapters/ # Markdown 章节源文件 assets/ # 图片资源 +data/ # 结构化数据源(CSV/YAML/JSON,通过 @import 引用) doc_builder/ # Python 构建工具 build.py # 构建入口 - templates/ # HTML 模板 - themes/ # CSS 样式 + templates/ # HTML 模板(含 A4 封面) + themes/ # CSS 样式(屏幕 + 打印) + renderers/ # 自定义渲染器(@import / 代码块) + processors/ # 数据处理器 + checks/ # 检查脚本 output/ # 构建产物(.gitignore) ``` @@ -33,7 +37,7 @@ pip install -r requirements.txt # 2. 构建 python doc_builder/build.py -# 3. 打开 output/读出子系统编程控制模型.html 即可浏览 +# 3. 打开 output/<标题>.html 即可浏览 ``` ### 编辑文档 @@ -42,6 +46,15 @@ python doc_builder/build.py - 添加/删除/重新排序章节:编辑 `project.yaml` 的 `chapters` 列表 - 修改样式:编辑 `doc_builder/themes/report.css` - 修改页面布局:编辑 `doc_builder/templates/report.html` +- 图片放在 `assets/` 目录,章节中通过 `../assets/xxx.png` 引用 + +### 非标准 Markdown 扩展 + +本项目支持以下扩展语法(仅在构建时生效): + +- **`@import "path"`** — 将数据文件或 Markdown 注入当前章节 +- **`@import "path" using render_xxx`** — 使用自定义渲染器 +- **`![alt](../assets/x.png){w=50%}`** — 图片属性控制 ### 编辑器推荐 @@ -49,9 +62,3 @@ python doc_builder/build.py - Typora - Obsidian - 任何支持 Markdown 的编辑器 - -## 向后兼容 - -原有的 Markdown Preview Enhanced (MPE) 导出方式仍然可用: -在 VS Code 中安装 MPE 插件后,打开 `读出子系统编程控制模型.md` 可实时预览和导出 HTML。 -不过建议优先使用 `doc_builder/build.py` 进行构建。 diff --git a/assets/logo.png b/assets/logo.png new file mode 100644 index 0000000..0840bc2 Binary files /dev/null and b/assets/logo.png differ diff --git a/chapters/03-overview.md b/chapters/03-overview.md index 83e6ca2..fcd0f7e 100644 --- a/chapters/03-overview.md +++ b/chapters/03-overview.md @@ -56,7 +56,7 @@ ez-Q 2.5 ASIC平台读出子系统由读出基带板、读出混频板和读出 - 读出混频板自身产生一个本振信号,用于实现基带信号与射频信号之间的转换; - 而读出泵浦板负责产生一个指定功率频率的单音信号,并在外部使能信号控制下输出; -![读出子系统组成](./assets/readout_system.png) +![读出子系统组成](../assets/readout_system.png) 当软件需要对不同通道编程时,其通过ip地址指定机箱、通过槽位号指定板卡、 通过扩展地址指定同一个板卡内的多个通道、通过地址指定一个通道内的不同配置项。 diff --git a/chapters/04-04-acq-matched-filter.md b/chapters/04-04-acq-matched-filter.md index eb5f9b2..d64a091 100644 --- a/chapters/04-04-acq-matched-filter.md +++ b/chapters/04-04-acq-matched-filter.md @@ -15,7 +15,7 @@ 读出系统的的读出参数存储格式如下图所示。 FPGA平台下的系数直读模式只使用`Ctrl`部分中的数据,并忽略`dds_pfw`控制字. -![读出ACQ通道控制模型](./assets/readout_para.png) +![读出ACQ通道控制模型](../assets/readout_para.png) ### 4.4.2. 匹配滤波器系数 @@ -29,4 +29,4 @@ ez-Q 2.5 FPGA平台使用系数直读模式,其需要额外的存储空间来 - 匹配滤波器的I和Q数据分开存储,每个比特的I、Q数据容量分别为16 KB。 - I路数据偏移地址为x*32KB,Q路数据偏移地址为x*32KB+16 KB,其中x为Qubit序号,范围为0~15。 -![读出ACQ通道控制模型](./assets/readout_mtf.png) +![读出ACQ通道控制模型](../assets/readout_mtf.png) diff --git a/chapters/05-03-exct-wavetable.md b/chapters/05-03-exct-wavetable.md index 354fe76..fbab03b 100644 --- a/chapters/05-03-exct-wavetable.md +++ b/chapters/05-03-exct-wavetable.md @@ -6,4 +6,4 @@ 索引表格式定义如下图所示: wave_id是mcu产生的码字,其可以作为地址索引波形控制参数。 波形控制参数包括波形地址`addr`和波形长度`len`参数,颗粒度是时钟周期。 -![波形查找表和波形仓库](./assets/readout_lut.png) +![波形查找表和波形仓库](../assets/readout_lut.png) diff --git a/chapters/05-exct-model.md b/chapters/05-exct-model.md index 45ba70c..2ca5f9d 100644 --- a/chapters/05-exct-model.md +++ b/chapters/05-exct-model.md @@ -9,7 +9,7 @@ MCU的指令、MCU的数据、控制寄存器、波形索性表、波形仓库 当前模拟电路仅需配置Pump参数,下图是EXC-Pump通道数字部分的编程控制模型。 -![读出RI-Pump通道控制模型](./assets/readout_ri.png) +![读出RI-Pump通道控制模型](../assets/readout_ri.png) 1. EXC-Pump通道的波形输出由AWG模块MCU发出的码字触发。 对于输出波形而言,MCU发出的码字定义波形的索引ID, @@ -29,7 +29,7 @@ MCU的指令、MCU的数据、控制寄存器、波形索性表、波形仓库 最后数字信号经过DAC转换成基带信号,基带信号再和外部本振信号模拟混频后输出读出激励波形。 读出芯片不同模式输出的频响曲线如下图所示: -![output_response](./assets/output_response.png) +![output_response](../assets/output_response.png) 为了兼容混频输出和射频直出两种工作模式以及在FPGA和ASIC平台上实现,半带滤波器和MIX模块都支持旁路功能,因此最终波形输出支持NRZ、MIX、HBNRZ和HBMIX四种模式。 diff --git a/doc_builder/build.py b/doc_builder/build.py index 2eda412..42ed1d1 100644 --- a/doc_builder/build.py +++ b/doc_builder/build.py @@ -2,204 +2,573 @@ """ Docs as Code — 构建入口 -读取 project.yaml → 拼接 chapters/*.md → 渲染为自包含 HTML 报告。 -纯 Python 实现,依赖 requirements.txt 中的 PyYAML, markdown, Jinja2。 +读取 project.yaml → 逐章节处理 @import → Markdown → HTML → 图片内嵌 +→ 组装 → 注入锚点 → 提取目录 → 模板渲染 → 自包含 HTML 报告。 用法: python doc_builder/build.py 输出: - output/读出子系统编程控制模型.html (自包含 HTML,可离线分发) + output/<标题>.html (自包含 HTML,可离线分发) """ -import os -import sys import base64 +import csv +import importlib.util +import io +import json import re +import sys +from datetime import date from pathlib import Path -import yaml import markdown -from jinja2 import Environment, FileSystemLoader +import yaml +from jinja2 import Environment, FileSystemLoader, select_autoescape +# ---------- 路径配置 ---------- +PROJECT_ROOT = Path(__file__).resolve().parent.parent +CHAPTERS_DIR = PROJECT_ROOT / "chapters" +ASSETS_DIR = PROJECT_ROOT / "assets" +OUTPUT_DIR = PROJECT_ROOT / "output" +BUILDER_DIR = PROJECT_ROOT / "doc_builder" +TEMPLATES_DIR = BUILDER_DIR / "templates" +THEMES_DIR = BUILDER_DIR / "themes" +RENDERERS_DIR = BUILDER_DIR / "renderers" +CHECKS_DIR = BUILDER_DIR / "checks" -# ---------- 配置 ---------- -ROOT = Path(__file__).resolve().parent.parent -CHAPTERS_DIR = ROOT / "chapters" -ASSETS_DIR = ROOT / "assets" -OUTPUT_DIR = ROOT / "output" -OUTPUT_NAME = "读出子系统编程控制模型.html" +# ---------- 配置加载 ---------- +def load_config(): + path = PROJECT_ROOT / "project.yaml" + if not path.exists(): + raise FileNotFoundError("找不到 project.yaml") + with open(path, "r", encoding="utf-8") as f: + config = yaml.safe_load(f) or {} -# ---------- 工具函数 ---------- - -def load_project_config() -> dict: - """读取 project.yaml 并校验。""" - config_path = ROOT / "project.yaml" - if not config_path.exists(): - sys.exit(f"错误: 找不到 {config_path}") - with open(config_path, "r", encoding="utf-8") as f: - config = yaml.safe_load(f) + config.setdefault("title", "未命名文档") + config.setdefault("subtitle", "") + config.setdefault("author", "") + config.setdefault("version", "") + config.setdefault("doc_type", "技术文档") + config.setdefault("logo", "") + config.setdefault("lang", "zh-CN") + config.setdefault("date", date.today().isoformat()) return config -def resolve_image_path(md_content: str, assets_dir: Path, inline_images: bool = True) -> tuple[str, dict]: +# ---------- 插件发现 ---------- + +def load_plugin_modules(directory): + """扫描目录下的 *.py 文件并导入为模块字典。""" + modules = {} + if not directory.exists(): + return modules + for py_file in sorted(directory.glob("*.py")): + if py_file.name.startswith("_"): + continue + try: + spec = importlib.util.spec_from_file_location(py_file.stem, py_file) + if spec is None or spec.loader is None: + continue + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + modules[py_file.stem] = mod + print(f" 已加载: {py_file.stem}") + except Exception as e: + print(f" 警告: 加载 {py_file.name} 失败: {e}") + return modules + + +# ---------- @import 处理 ---------- + +IMPORT_RE = re.compile(r'^@import\s+"([^"]+)"(?:\s+using\s+(\S+))?\s*$', re.MULTILINE) + + +def resolve_import_path(raw, base_dir): + path = Path(raw) + if path.is_absolute(): + return path + return (base_dir / path).resolve() + + +def default_data_renderer(filepath): + """默认数据文件渲染:CSV → HTML 表格,YAML/JSON → 代码块。""" + ext = filepath.suffix.lower() + if ext == ".csv": + with open(filepath, newline="", encoding="utf-8") as f: + reader = csv.reader(f) + rows = list(reader) + if not rows: + return "" + lines = [""] + lines.append("" + "".join(f"" for c in rows[0]) + "") + for row in rows[1:]: + lines.append("" + "".join(f"" for c in row) + "") + lines.append("
{c}
{c}
") + return "".join(lines) + elif ext in (".yaml", ".yml"): + with open(filepath, "r", encoding="utf-8") as f: + data = yaml.safe_load(f) or {} + return f"
{yaml.dump(data, allow_unicode=True)}
" + elif ext == ".json": + with open(filepath, "r", encoding="utf-8") as f: + data = json.load(f) + return f"
{json.dumps(data, ensure_ascii=False, indent=2)}
" + else: + content = filepath.read_text(encoding="utf-8") + return f"
{content}
" + + +def render_with_module(renderers, renderer_name, filepath): + """调用指定渲染器。""" + if renderer_name not in renderers: + raise ValueError(f"找不到指定渲染器:{renderer_name}") + func = getattr(renderers[renderer_name], "render", None) + if not callable(func): + raise ValueError(f"{renderer_name} 没有 render(content: str) -> str 函数") + return func(str(filepath)) + + +def rewrite_image_paths(text, source_dir): + """把被导入 Markdown 中的相对图片路径改为绝对路径,供后续 base64 嵌入。""" + def repl(match): + alt = match.group(1) + src = match.group(2) + if src.startswith(("http://", "https://", "data:")) or Path(src).is_absolute(): + return match.group(0) + abs_path = (source_dir / src).resolve() + return f'![{alt}]({abs_path})' + return re.sub(r'!\[([^\]]*)\]\(([^)]+)\)', repl, text) + + +def process_imports(text, base_dir, renderers, _imported=None): + """扫描并替换 @import 指令。支持 .md 注入和数据文件导入。""" + if _imported is None: + _imported = set() + + def repl(match): + raw = match.group(1) + renderer_name = match.group(2) + target = resolve_import_path(raw, base_dir) + + # 外部 Markdown 导入 + if raw.endswith(".md"): + if target in _imported: + raise RuntimeError(f"检测到循环 @import:{target}") + _imported.add(target) + if not target.exists(): + raise FileNotFoundError(f"找不到要导入的 Markdown 文件:{target}") + md = target.read_text(encoding="utf-8") + md = rewrite_image_paths(md, target.parent) + md = process_imports(md, target.parent, renderers, _imported) + return md + + # 数据导入 + if not target.exists(): + raise FileNotFoundError(f"找不到要导入的数据文件:{target}") + if renderer_name: + renderer_name = renderer_name.removesuffix(".py") + return render_with_module(renderers, renderer_name, target) + return default_data_renderer(target) + + return IMPORT_RE.sub(repl, text) + + +# ---------- 图片属性 {w=50%} ---------- + +IMAGE_ATTR_RE = re.compile(r'!\[([^\]]*)\]\(([^)]+)\)\{([^}]*)\}') + + +def parse_attrs(attr_str): + attrs = {} + for part in attr_str.split(","): + part = part.strip() + if "=" in part: + k, v = part.split("=", 1) + attrs[k.strip()] = v.strip() + return attrs + + +def process_image_attrs(text): + """处理 ![](path){w=50%} 图片属性语法,转换为 HTML img 标签。""" + def repl(match): + alt = match.group(1) + src = match.group(2) + attr_str = match.group(3) + attrs = parse_attrs(attr_str) + style = "" + if "w" in attrs: + style = f'width:{attrs["w"]};' + cls = attrs.get("class", "") + cls_attr = f' class="{cls}"' if cls else "" + style_attr = f' style="{style}"' if style else "" + return f'{alt}' + return IMAGE_ATTR_RE.sub(repl, text) + + +# ---------- KaTeX 预处理 ---------- + +def preprocess_katex(text): """ - 处理 Markdown 中的图片引用: - - 如果 inline_images=True,将图片内嵌为 base64 data URI - - 否则转换为相对路径引用 - - 返回: (处理后的内容, {原始路径: data_uri 字典}) + 将 Markdown 中的 LaTeX 公式包装为原始 HTML, + 防止 Markdown 解析器错误解释公式中的 _ * 等字符。 """ - image_map = {} + # 块级公式 $$...$$ + def protect_display(match): + latex = match.group(1) + return f'
$${latex}$$
' + text = re.sub(r'\$\$\s*(.+?)\s*\$\$', protect_display, text, flags=re.DOTALL) - def replace_img(match): - alt_text = match.group(1) - img_path = match.group(2) + # 行内公式 $...$ + def protect_inline(match): + latex = match.group(1) + return f'${latex}$' + text = re.sub(r'(?= len(lines): + out_lines.append(line) + i += 1 + continue + + content = "".join(lines[start:j]).rstrip("\n") + rendered = func(content) + if not rendered.endswith("\n"): + rendered += "\n" + out_lines.append(rendered) + # 跳过 fence 后的换行 + if j + 1 < len(lines) and lines[j + 1] == "\n": + i = j + 2 + else: + i = j + 1 + + return "".join(out_lines) + + +# ---------- Markdown → HTML ---------- + +def markdown_to_html(text): + md = markdown.Markdown(extensions=[ + "extra", + "tables", + "fenced_code", + "toc", + ]) + return md.convert(text) + + +# ---------- 图片 base64 内嵌(HTML 级别) ---------- + +IMG_SRC_RE = re.compile(r']*?)src=["\']([^"\']+)["\']([^>]*)>', re.IGNORECASE) + +MIME_TABLE = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".svg": "image/svg+xml", + ".webp": "image/webp", +} + + +def guess_mime(ext): + return MIME_TABLE.get(ext.lower(), "application/octet-stream") + + +def embed_images(html, base_dirs): + """扫描 HTML 中的 img 标签,将本地图片替换为 base64 data URI。""" + def resolve_src(src): + if Path(src).is_absolute(): + path = Path(src) + if path.exists(): + return path + return None + for base in base_dirs: + path = base / src + if path.exists(): + return path + return None + + def repl(match): + prefix = match.group(1) + src = match.group(2) + suffix = match.group(3) + + if src.startswith(("http://", "https://", "data:")): return match.group(0) - if inline_images: - # 内嵌为 base64 - with open(full_path, "rb") as img_file: - img_data = base64.b64encode(img_file.read()).decode("ascii") - ext = full_path.suffix.lower() - mime_map = {".png": "image/png", ".svg": "image/svg+xml", - ".jpg": "image/jpeg", ".jpeg": "image/jpeg", - ".gif": "image/gif"} - mime = mime_map.get(ext, "image/png") - data_uri = f"data:{mime};base64,{img_data}" - image_map[img_path] = data_uri - return f"![{alt_text}]({data_uri})" - else: - return f"![{alt_text}](assets/{img_path})" + path = resolve_src(src) + if path is None: + print(f" [警告] 找不到图片,保留原路径:{src}") + return match.group(0) + try: + mime = guess_mime(path.suffix) + data = path.read_bytes() + b64 = base64.b64encode(data).decode("ascii") + return f'' + except Exception as e: + print(f" [警告] 图片 base64 编码失败({src}):{e}") + return match.group(0) - return re.sub(r'!\[([^\]]*)\]\(\./assets/([^)]+)\)', replace_img, md_content), image_map + return IMG_SRC_RE.sub(repl, html) -def read_and_assemble(config: dict) -> str: - """按 project.yaml 的章节列表拼接所有 chapters/*.md 文件。""" - chapters = config.get("chapters", []) - if not chapters: - sys.exit("错误: project.yaml 中未定义 chapters 列表") +# ---------- 目录与锚点 ---------- - parts = [] - for ch_file in chapters: - ch_path = CHAPTERS_DIR / ch_file - if not ch_path.exists(): - print(f" 警告: 章节文件不存在,跳过: {ch_file}") - continue - with open(ch_path, "r", encoding="utf-8") as f: - content = f.read() - parts.append(content) - - return "\n\n".join(parts) +def slugify(text): + """生成 HTML 锚点 ID。""" + anchor = re.sub(r'[^\w\s一-鿿-]', '', text) + return anchor.strip().replace(" ", "-")[:50] -def extract_toc(md_content: str) -> list[dict]: - """从 Markdown 内容中提取目录结构 (H1, H2)。""" +def generate_toc(html): + """从 HTML 中提取 H1-H4 标题,生成目录。""" toc = [] - for line in md_content.split("\n"): - m = re.match(r'^(#{1,3})\s+(.+)$', line) - if m: - level = len(m.group(1)) - title = m.group(2).strip() - # 生成锚点:去除特殊字符,空格转连字符 - anchor = re.sub(r'[^\w\s一-鿿-]', '', title) - anchor = anchor.strip().replace(' ', '-').lower() - toc.append({"level": level, "title": title, "anchor": anchor}) + for m in re.finditer(r']*>(.*?)', html, re.DOTALL): + level = int(m.group(1)) + text = re.sub(r'<.*?>', '', m.group(2)).strip() + toc.append({"level": level, "text": text, "anchor": slugify(text)}) return toc -# ---------- HTML 生成 ---------- - -def generate_html(md_content: str, config: dict) -> str: - """将 Markdown 内容转换为完整的 HTML 页面。""" - - # 处理图片内嵌 - processed_md, _ = resolve_image_path(md_content, ASSETS_DIR, inline_images=True) - - # Markdown → HTML - md_extensions = [ - "markdown.extensions.tables", - "markdown.extensions.fenced_code", - "markdown.extensions.codehilite", - "markdown.extensions.toc", - "markdown.extensions.nl2br", - ] - html_body = markdown.markdown(processed_md, extensions=md_extensions) - - # 提取目录 - toc = extract_toc(md_content) - - # 加载 Jinja2 模板 - templates_dir = ROOT / "doc_builder" / "templates" - env = Environment(loader=FileSystemLoader(str(templates_dir))) - template = env.get_template("report.html") - - # 读取主题 CSS - themes_dir = ROOT / "doc_builder" / "themes" - css_screen = "" - css_print = "" - screen_css_path = themes_dir / "report.css" - print_css_path = themes_dir / "print.css" - if screen_css_path.exists(): - css_screen = screen_css_path.read_text(encoding="utf-8") - if print_css_path.exists(): - css_print = print_css_path.read_text(encoding="utf-8") - - return template.render( - title=config.get("title", "文档"), - subtitle=config.get("subtitle", ""), - author=config.get("author", ""), - version=config.get("version", ""), - toc=toc, - body=html_body, - css_screen=css_screen, - css_print=css_print, +def inject_anchors(html): + """为所有 H1-H4 标签注入 id 属性,使侧边栏目录可跳转。""" + def repl(match): + level = match.group(1) + attrs = match.group(2) + inner = match.group(3) + anchor = slugify(re.sub(r'<.*?>', '', inner).strip()) + return f'{inner}' + return re.sub( + r']*)>(.*?)', + repl, + html, + flags=re.DOTALL, ) +# ---------- Logo 处理 ---------- + +def find_logo(logo_path_str): + """定位 logo 文件;需在 project.yaml 明确指定 logo 字段。""" + if not logo_path_str: + return None + path = Path(logo_path_str) + if path.is_absolute(): + return path + return PROJECT_ROOT / path + + +def embed_logo(logo_path, max_width=400): + """压缩 logo 并返回 base64 data URI。""" + if logo_path is None: + return None + + try: + from PIL import Image + except ImportError: + print(" 提示: 未安装 Pillow,跳过 logo 处理") + return None + + try: + img = Image.open(logo_path) + w, h = img.size + if w > max_width: + ratio = max_width / w + img = img.resize((max_width, int(h * ratio)), Image.Resampling.LANCZOS) + + ext = logo_path.suffix.lower() + if ext == ".svg": + data = logo_path.read_bytes() + b64 = base64.b64encode(data).decode("ascii") + return f"data:image/svg+xml;base64,{b64}" + + buf = io.BytesIO() + if img.mode in ("RGBA", "P"): + img.save(buf, format="PNG", optimize=True) + mime = "image/png" + else: + img = img.convert("RGB") + img.save(buf, format="JPEG", optimize=True, quality=90) + mime = "image/jpeg" + b64 = base64.b64encode(buf.getvalue()).decode("ascii") + return f"data:{mime};base64,{b64}" + except Exception as e: + print(f" 警告: logo 处理失败: {e}") + return None + + +# ---------- 模板渲染 ---------- + +def inline_css(): + """读取主题 CSS。""" + css_path = THEMES_DIR / "report.css" + if css_path.exists(): + return css_path.read_text(encoding="utf-8") + return "" + + +def render_template(config, content, toc, logo_data_uri=None): + env = Environment( + loader=FileSystemLoader(TEMPLATES_DIR), + autoescape=select_autoescape(["html", "xml"]), + ) + template = env.get_template("report.html") + css = inline_css() + return template.render( + title=config["title"], + subtitle=config["subtitle"], + author=config["author"], + version=config["version"], + doc_type=config["doc_type"], + logo=logo_data_uri, + date=config["date"], + content=content, + toc=toc, + css=css, + ) + + +# ---------- 检查脚本 ---------- + +def run_checks(checks): + issues = [] + for name, module in checks.items(): + func = getattr(module, "check", None) + if not callable(func): + continue + try: + result = func(str(PROJECT_ROOT)) + if result: + issues.extend(result) + except Exception as e: + print(f" 警告: 检查脚本 {name} 执行失败: {e}") + if issues: + print("检查发现问题:") + for item in issues: + print(f" - {item}") + + # ---------- 主入口 ---------- def main(): - print("=== ez-Q 2.5 读出子系统编程控制模型 构建 ===") + print("=== Docs as Code 构建 ===") print() - # 1. 加载配置 - print("[1/3] 读取 project.yaml ...") - config = load_project_config() - print(f" 项目: {config.get('title', '未命名')}") - print(f" 版本: {config.get('version', 'N/A')}") + # 0. 加载配置 + config = load_config() + print(f"[配置] {config['title']} — {config['version']}") print(f" 章节数: {len(config.get('chapters', []))}") - # 2. 拼接章节 - print("[2/3] 拼接章节 ...") - assembled = read_and_assemble(config) - print(f" 总字符数: {len(assembled)}") + # 0a. 加载插件 + print("[插件] 加载扩展模块 ...") + renderers = load_plugin_modules(RENDERERS_DIR) + checks = load_plugin_modules(CHECKS_DIR) - # 3. 生成 HTML - print("[3/3] 生成 HTML ...") - html = generate_html(assembled, config) + # 0b. 运行检查 + if config.get("checks", True) and checks: + print("[检查] 运行检查脚本 ...") + run_checks(checks) - # 4. 输出 + # 1. 逐章节处理 + chapters = config.get("chapters", []) + if not chapters: + sys.exit("错误: project.yaml 中未定义 chapters 列表") + + html_parts = [] + for ch_file in chapters: + ch_path = CHAPTERS_DIR / ch_file + if not ch_path.exists(): + print(f" 警告: 章节文件不存在,跳过: {ch_file}") + continue + + text = ch_path.read_text(encoding="utf-8") + + # @import 处理 + text = process_imports(text, ch_path.parent, renderers) + + # 图片属性处理 + text = process_image_attrs(text) + + # KaTeX 预处理 + text = preprocess_katex(text) + + # 自定义代码块渲染 + text = render_custom_codeblocks_md(text, renderers) + + # Markdown → HTML + chapter_html = markdown_to_html(text) + + # 图片 base64 内嵌 + base_dirs = [ch_path.parent, ASSETS_DIR, PROJECT_ROOT] + chapter_html = embed_images(chapter_html, base_dirs) + + html_parts.append(chapter_html) + + full_html = "\n\n".join(html_parts) + print(f" HTML 总字符数: {len(full_html)}") + + # 2. 注入锚点 + 提取目录 + full_html = inject_anchors(full_html) + toc = generate_toc(full_html) + print(f" 目录条目数: {len(toc)}") + + # 3. Logo 处理 + logo_path = find_logo(config.get("logo", "")) + logo_data_uri = embed_logo(logo_path) if logo_path else None + if logo_data_uri: + print(" logo: 已内嵌") + else: + print(" logo: 未配置,封面将不显示 logo") + + # 4. 模板渲染 + 输出 + output_filename = re.sub(r'[^\w\-.]', '_', config["title"]) + ".html" OUTPUT_DIR.mkdir(parents=True, exist_ok=True) - output_path = OUTPUT_DIR / OUTPUT_NAME - output_path.write_text(html, encoding="utf-8") - print(f" 输出: {output_path}") - print(f" 文件大小: {output_path.stat().st_size:,} 字节") + output_path = OUTPUT_DIR / output_filename - print() - print("Build completed successfully!") + final_html = render_template(config, full_html, toc, logo_data_uri) + output_path.write_text(final_html, encoding="utf-8") + print(f"\n构建完成:{output_path}") + print(f"文件大小: {output_path.stat().st_size:,} 字节") if __name__ == "__main__": diff --git a/doc_builder/templates/report.html b/doc_builder/templates/report.html index c1ff204..35b6087 100644 --- a/doc_builder/templates/report.html +++ b/doc_builder/templates/report.html @@ -1,74 +1,63 @@ - + - - {{ title }} — {{ version }} + {{ title }} {# KaTeX 数学公式支持 #} - - - {# 打印样式 #} - + +
+ -
-

{{ title }}

- {% if subtitle %} -

{{ subtitle }}

- {% endif %} -
- 版本: {{ version }} - 作者: {{ author }} +
+
+ {% if logo %} + + {% endif %} +
{{ doc_type }}
+

{{ title }}

+ {% if subtitle %}

{{ subtitle }}

{% endif %} +
+ + {% if author %}{% endif %} + {% if version %}{% endif %} + +
作者{{ author }}
版本{{ version }}
日期{{ date }}
+
+
+ +
+ {{ content | safe }} +
+
-
-{# 侧边栏目录 #} - - -{# 正文内容 #} -
- {{ body }} -
- -
-

{{ title }} — {{ version }} — {{ author }}

-
- -{# KaTeX 自动渲染 #} - - + diff --git a/doc_builder/themes/print.css b/doc_builder/themes/print.css deleted file mode 100644 index ac42f44..0000000 --- a/doc_builder/themes/print.css +++ /dev/null @@ -1,60 +0,0 @@ -/* ============================================ - ez-Q 2.5 读出子系统编程控制模型 — 打印样式 - ============================================ */ - -@media print { - body { - font-size: 12pt; - color: #000; - background: #fff; - } - - .sidebar-toc { - display: none; - } - - .report-header { - background: none; - color: #000; - padding: 1em 0; - border-bottom: 2px solid #000; - } - - .report-body { - max-width: 100%; - padding: 1em 0; - } - - .report-body h1 { - page-break-before: always; - font-size: 16pt; - } - - .report-body h2 { - font-size: 14pt; - } - - .report-body h3 { - font-size: 12pt; - } - - .report-body pre { - border: 1px solid #ccc; - background: #f9f9f9; - page-break-inside: avoid; - } - - .report-body table { - page-break-inside: avoid; - } - - .report-body img { - max-width: 100%; - page-break-inside: avoid; - } - - .report-footer { - border-top: 1px solid #000; - font-size: 10pt; - } -} diff --git a/doc_builder/themes/report.css b/doc_builder/themes/report.css index 870471d..694bc59 100644 --- a/doc_builder/themes/report.css +++ b/doc_builder/themes/report.css @@ -1,222 +1,280 @@ /* ============================================ - ez-Q 2.5 读出子系统编程控制模型 — 屏幕样式 + Docs as Code — 屏幕与打印样式 + 严格对齐 docs-as-code skill 参考实现 ============================================ */ :root { - --primary: #1a237e; - --accent: #283593; - --bg-light: #f5f5f5; - --bg-white: #ffffff; - --text: #333333; - --text-light: #5c5c5c; - --border: #d6d6d6; - --code-bg: #f0f0f0; - --sidebar-width: 280px; + --primary: #0d47a1; + --secondary: #1565c0; + --accent: #42a5f5; + --light: #e3f2fd; + --text: #212121; + --muted: #616161; + --bg: #f0f2f5; + --sidebar-w: 260px; + --content-w: 210mm; + --a4-h: 297mm; + --cover-px: 25mm; } -* { - box-sizing: border-box; - margin: 0; - padding: 0; -} +* { margin: 0; padding: 0; box-sizing: border-box; } + +html { scroll-behavior: smooth; } body { - font-family: 'Helvetica Neue', Helvetica, 'Segoe UI', Arial, 'Microsoft YaHei', sans-serif; - font-size: 16px; + font-family: "Noto Sans SC", "Microsoft YaHei", "PingFang SC", sans-serif; + font-size: 11pt; line-height: 1.8; color: var(--text); - background-color: var(--bg-white); + background: var(--bg); +} + +/* ---- 整体布局:左侧目录 + 右侧主内容 ---- */ +.layout { display: flex; - flex-wrap: wrap; + max-width: calc(var(--sidebar-w) + var(--content-w)); + margin: 0 auto; + background: #fff; + min-height: 100vh; } -/* ---- 页头 ---- */ -.report-header { - width: 100%; - background: linear-gradient(135deg, var(--primary), var(--accent)); - color: white; - padding: 2em 2em 1.5em; - text-align: center; -} - -.report-title { - font-size: 2em; - font-weight: 600; - margin-bottom: 0.25em; -} - -.report-subtitle { - font-size: 1.1em; - opacity: 0.85; - margin-bottom: 0.75em; -} - -.report-meta { - font-size: 0.9em; - opacity: 0.8; -} - -.report-meta span { - margin: 0 1em; -} - -/* ---- 侧边栏 ---- */ -.sidebar-toc { - width: var(--sidebar-width); - min-width: var(--sidebar-width); - padding: 1.5em 1em; - background: var(--bg-light); - border-right: 1px solid var(--border); +.sidebar { position: sticky; top: 0; + width: var(--sidebar-w); height: 100vh; overflow-y: auto; - font-size: 14px; + flex-shrink: 0; + background: #fafbfc; + border-right: 1px solid #e0e0e0; + padding: 24px 18px; } -.sidebar-toc h2 { - font-size: 1.1em; - margin-bottom: 0.75em; +.sidebar h2 { + font-size: 13pt; color: var(--primary); -} - -.sidebar-toc ul { - list-style: none; - padding: 0; -} - -.sidebar-toc li { - margin: 0.25em 0; -} - -.sidebar-toc a { - color: var(--text); - text-decoration: none; - display: block; - padding: 0.15em 0; -} - -.sidebar-toc a:hover { - color: var(--primary); -} - -.toc-h1 { font-weight: 600; margin-top: 0.5em; } -.toc-h2 { padding-left: 1em; } -.toc-h3 { padding-left: 2em; font-size: 0.95em; color: var(--text-light); } - -/* ---- 正文 ---- */ -.report-body { - flex: 1; - min-width: 0; - max-width: 900px; - padding: 2em 2.5em 4em; -} - -.report-body h1 { - font-size: 1.8em; - font-weight: 600; - margin: 1.2em 0 0.6em; - padding-bottom: 0.3em; border-bottom: 2px solid var(--primary); - color: var(--primary); + padding-bottom: 8px; + margin-bottom: 14px; + font-weight: 700; } -.report-body h2 { - font-size: 1.4em; - font-weight: 600; - margin: 1em 0 0.5em; - color: var(--accent); -} +.sidebar ul { list-style: none; padding: 0; } -.report-body h3 { - font-size: 1.15em; - font-weight: 600; - margin: 0.8em 0 0.4em; -} - -.report-body p { - margin: 0.5em 0; -} - -.report-body img { - max-width: 100%; - display: block; - margin: 1em auto; -} - -/* ---- 表格 ---- */ -.report-body table { - width: 100%; - border-collapse: collapse; - margin: 0.75em 0 1.25em; - font-size: 0.95em; -} - -.report-body th, -.report-body td { - border: 1px solid var(--border); - padding: 6px 12px; - text-align: left; -} - -.report-body th { - background: var(--bg-light); - font-weight: 600; -} - -/* ---- 代码块 ---- */ -.report-body code { - font-family: Consolas, 'Liberation Mono', Menlo, Courier, monospace; - font-size: 0.9em; - background: var(--code-bg); - padding: 0.15em 0.35em; - border-radius: 3px; -} - -.report-body pre { - background: var(--bg-light); - padding: 1em 1.2em; - border-radius: 4px; - border: 1px solid var(--border); - overflow-x: auto; - margin: 0.75em 0; - font-size: 0.9em; +.sidebar li { + padding: 4px 0; + font-size: 9.5pt; line-height: 1.5; } -.report-body pre code { - background: none; - padding: 0; +.sidebar li a { + color: #555; + text-decoration: none; + display: block; + border-radius: 4px; + padding: 3px 8px; + transition: all 0.15s; } -/* ---- 引用块 ---- */ -.report-body blockquote { - margin: 0.75em 0; - padding: 0.5em 1em; - background: var(--bg-light); - border-left: 4px solid var(--border); - color: var(--text-light); +.sidebar li a:hover { + color: var(--primary); + background: var(--light); } -/* ---- 列表 ---- */ -.report-body ul, -.report-body ol { - margin: 0.5em 0; - padding-left: 2em; +.sidebar .toc-level-2 { padding-left: 12px; } +.sidebar .toc-level-3 { padding-left: 24px; } +.sidebar .toc-level-4 { padding-left: 36px; } + +.main { + flex: 1; + max-width: var(--content-w); + min-width: 0; + background: #fff; } -.report-body li { - margin: 0.15em 0; -} - -/* ---- 页脚 ---- */ -.report-footer { +/* ---- 封面(A4) ---- */ +.cover { width: 100%; + height: var(--a4-h); + min-height: var(--a4-h); + padding: 35mm var(--cover-px) 50mm; + background: #fff; + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: center; text-align: center; - padding: 1.5em; - font-size: 0.85em; - color: var(--text-light); - border-top: 1px solid var(--border); + page-break-after: always; + border-bottom: 1px solid #e0e0e0; +} + +.cover-logo { + max-width: 320px; + max-height: 90px; + height: auto; + width: auto; + margin: 0; + object-fit: contain; +} + +.cover-doc-type { + font-size: 13pt; + font-weight: 500; + color: var(--secondary); + letter-spacing: 0.5em; + text-indent: 0.5em; + margin-top: 15mm; + margin-bottom: 15mm; +} + +.cover-title { + font-family: "Noto Sans SC", "Microsoft YaHei", "PingFang SC", sans-serif; + font-size: 26pt; + font-weight: 700; + color: #fff; + background: var(--primary); + line-height: 1.4; + margin: 0 calc(-1 * var(--cover-px)) 0; + padding: 0.4em var(--cover-px); + width: calc(100% + 2 * var(--cover-px)); +} + +.cover-subtitle { + font-size: 16pt; + font-weight: 700; + color: var(--secondary); + margin-top: 8mm; + margin-bottom: 18mm; +} + +.cover-meta { + width: 100%; + max-width: 130mm; + margin-top: auto; + padding-bottom: 0; +} + +.cover-meta table { + width: 100%; + border-collapse: collapse; + font-size: 11pt; + color: var(--muted); +} + +.cover-meta td { + padding: 2.5mm 4mm; + border-bottom: 1px solid #e0e0e0; + vertical-align: middle; + width: 50%; +} + +.cover-meta td:first-child { + text-align: center; + color: #9e9e9e; + font-weight: 500; +} + +.cover-meta td:last-child { + text-align: center; + color: var(--text); + font-weight: 500; +} + +/* ---- 正文 ---- */ +.content { + padding: 2cm 2.5cm 3cm; + background: #fff; +} + +/* ---- 正文排版 ---- */ +h1 { + font-family: "Noto Serif SC", "SimSun", serif; + font-size: 18pt; + font-weight: 700; + color: var(--primary); + border-bottom: 2px solid var(--primary); + padding-bottom: 0.3em; + margin-top: 1.8em; + line-height: 1.4; +} + +h2 { + font-family: "Noto Serif SC", "SimSun", serif; + font-size: 14pt; + font-weight: 600; + color: var(--secondary); + border-bottom: 1px solid var(--light); + padding-bottom: 0.2em; + margin-top: 1.5em; +} + +h3 { + font-size: 12pt; + font-weight: 600; + color: #303f9f; + margin-top: 1.3em; +} + +h4 { font-size: 11pt; font-weight: 600; } + +p { margin: 0.6em 0; } + +strong { color: var(--primary); } + +ul, ol { padding-left: 2em; margin: 0.6em 0; } +li { margin: 0.25em 0; } + +/* ---- 表格 ---- */ +table { + font-size: 10pt; + border-collapse: collapse; + width: 100%; + margin: 1em 0; +} + +th { + background: var(--primary); + color: #fff; + font-weight: 500; + text-align: center; + padding: 8px 12px; +} + +td { + padding: 6px 12px; + border: 1px solid #e0e0e0; + text-align: left; +} + +tbody tr:nth-child(even) { background: #f8f9fa; } + +/* ---- 代码 ---- */ +pre { + background: #f5f5f5; + border: 1px solid #e0e0e0; + border-radius: 4px; + padding: 12px 16px; + overflow-x: auto; + margin: 1em 0; +} + +code { + font-family: "JetBrains Mono", "Cascadia Code", "Consolas", monospace; + font-size: 0.9em; + background: #f0f0f0; + padding: 1px 4px; + border-radius: 3px; +} + +pre code { background: none; padding: 0; } + +/* ---- 图片(无阴影) ---- */ +img { + max-width: 100%; + height: auto; + display: block; + margin: 1em auto; + border-radius: 4px; } /* ---- KaTeX 公式 ---- */ @@ -226,21 +284,45 @@ body { overflow-y: hidden; } -/* ---- 移动端响应式 ---- */ -@media screen and (max-width: 768px) { - .sidebar-toc { - display: none; +/* ---- 响应式 ---- */ +@media screen and (max-width: 1100px) { + :root { --cover-px: 15mm; } + .layout { flex-direction: column; } + .sidebar { + width: 100%; + height: auto; + position: relative; + border-right: none; + border-bottom: 1px solid #e0e0e0; } - - .report-body { - padding: 1em 1.2em 3em; + .main { max-width: none; } + .content { padding: 1.5cm; } + .cover { + width: calc(100% - 2rem); + height: auto; + min-height: auto; + padding: 15mm; + margin: 1rem auto; } + .cover-title { font-size: 22pt; } + .cover-subtitle { font-size: 14pt; } +} - .report-header { - padding: 1.5em 1em 1em; +/* ---- 打印 ---- */ +@media print { + body { background: #fff; } + .layout { display: block; max-width: none; } + .sidebar { display: none; } + .main { max-width: none; } + .cover { + margin: 0; + width: 100%; + height: 100vh; + min-height: 100vh; + page-break-after: always; } - - .report-title { - font-size: 1.5em; + .content { + max-width: none; + padding: 0; } } diff --git a/project.yaml b/project.yaml index 10c3491..21b9ad1 100644 --- a/project.yaml +++ b/project.yaml @@ -2,7 +2,9 @@ title: 读出子系统历史无关功能配置项 subtitle: ez-Q 2.5 读出子系统编程控制模型 author: 郭成 -version: V0.2 +version: V0.3 +doc_type: 用户手册 +logo: assets/logo.png # 封面 logo 路径(需明确指定;不指定则无 logo) lang: zh-CN chapters: diff --git a/requirements.txt b/requirements.txt index 04cd991..bced9e3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,7 @@ # 安装: pip install -r requirements.txt PyYAML>=6.0 markdown>=3.5 +pymdown-extensions>=10.0 Jinja2>=3.1 +Pygments>=2.16 +Pillow>=10.0