diff --git a/DESIGN.md b/DESIGN.md index 596da64..7a4a1dd 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -642,6 +642,20 @@ create index on usage_events (model_profile, created_at); **前端取舍(2026-06-18 定 + 落地):对话端做完整 CRUD,前端只读展示 + 停用/删除。** 前端 SPA 调 `/v1/*` REST、不经 agent → "界面建/改定时任务"必须另开 REST + 表单 + cron 构建器(整套最重的是让科研用户填 cron 的 UX)。既然产品本就是对话式 agent,把建/改/删/查全收到对话(`schedule_*` 工具),**前端退化成只读看板**:`GET /v1/schedules` 列表 + 列表项「停用/删除」两个高频便捷动作(`PATCH`/`DELETE /v1/schedules/{id}`)。好处:cron 构建器 UX 难题直接消失(用户从不在前端填 cron,对 bot 说"每天早九点"由模型翻译);无"前端改了和对话不同步"的状态问题。代价:界面不能新建/编辑(需求低频,且对话更自然)。落地:`web/static/js/crons.js` 只读 master-detail modal(复用 skills modal 范式)+ 左栏 rail「定时」入口;工具与 REST 共用 `core.scheduler` CRUD 服务层不漂移。 +### 8.6 平台渲染层 rendering/(2026-06-23,✅ 已落地) + +**心智:文档渲染(md→docx/pdf)是平台能力,不是 skill 内容。** 像 `chromium` / `document_search` / `python` 一样,skill **调用**它而非各自 bundle 一份。 + +**起因**:`_CHEM_RE` 化学式下标白名单在 brief/paper/proposal **三份 render_docx.py 逐字重复**(改一处易漏改),patent/standard 还复用 proposal 那份;且 brief 缺 PDF 路径,模型临场手搓 weasyprint + 运行时 pip(线上事故)。 + +**为什么不放 `skills/_shared/` 让各 skill `import`**:Skills 走 Anthropic 自包含/渐进披露/可 fork bundle 标准(§3.5),`fork_skill` 把内置 skill 整份拷到用户 `.skills`。跨 skill `import skills._shared` 会破坏 fork(用户拷贝里 import 不到内置树)且 sys.path 脆。故抽到**顶层 `rendering/` 平台包**,bind-mount 进 `/sandbox/rendering`(pool.py,与 skills 同款 `:ro`),与 skill bundle 正交。 + +**结构**:`common.py`(叶子原语单一事实源:字体 OOXML/`CHEM_RE`/块级正则/表格行切分/图片路径)+ `docx_manuscript.py`(paper 投稿稿 + proposal 申报书,配置化双 profile:页边距/TOC/图题前缀/列表模式/分页策略)+ `docx_brief.py`(brief 简报富渲染:商务红 + 引文上标超链 + callout,复用 common 叶子)+ `pdf.py`(md→HTML→沙盒 chromium `--print-to-pdf`,复用 `common.CHEM_RE`)+ `render.py`(统一 CLI `--profile {brief,paper,proposal} --format {docx,pdf}`)。各 skill SKILL.md 调 `python /sandbox/rendering/render.py`,不再自带 render_docx.py。 + +**PDF 用 chromium 不用 weasyprint**:chromium 镜像已装(给 mermaid),fonts-noto-cjk 已装,完整浏览器内核 CSS 保真度高;weasyprint 要 pango/cairo 原生库、不在仓库 Dockerfile。**与 §8.3 pptx 预览分工**:pptx 预览在 web host 调 LibreOffice(面向用户的高保真预览,不进沙盒);本层在沙盒内 chromium 渲染(agent 生成阶段产出 docx/pdf 交付物)。 + +**取舍**:重构对三 profile 各渲前后 diff `word/document.xml` **字节一致**(零回归);brief 不强并进 manuscript 路径(引文/配色差异大,只共用叶子原语,降回归面)。 + --- ## 附录:DeepSeek V4 关键事实(2026-04-24) diff --git a/PROGRESS.md b/PROGRESS.md index de7a357..b8de89f 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。 -最后更新:2026-06-23(前端修复:消息目录点第一个圆点误高亮第二个 + bump 0.20.4) +最后更新:2026-06-23(平台渲染层 rendering/:三 skill docx 统一 + chromium md→pdf + bump 0.21.0) --- @@ -21,6 +21,17 @@ ## 已完成关键能力 +### 2026-06-23 / 平台渲染层 rendering/:三 skill docx 统一 + chromium md→pdf(bump 0.21.0) + +- 背景:线上 `简报` task 用户要"输出为pdf",模型因 brief 无 PDF 路径而临场即兴——试 `apt install libreoffice`(只读 fs 失败)→ `pip install weasyprint markdown` 手搓 md→HTML→weasyprint;容器空闲回收后包不持久,二次导出又重装一遍。深挖发现两个问题:① skill 缺 PDF 路径、weasyprint 不在镜像;② `_CHEM_RE` 化学式白名单在 brief/paper/proposal **三份 render_docx.py 逐字重复**(改一处易漏改),patent/standard 还复用 proposal 那份。 +- 架构判断:**渲染不是 skill 内容,是平台能力**(像 chromium/document_search)。Skills 走 Anthropic 自包含/可 fork bundle 标准,把共享渲染库塞 `skills/_shared` 让各 skill `import` 会破坏 fork。故新建**顶层 `rendering/` 平台包**,bind-mount 进 `/sandbox/rendering`(pool.py,与 skills 同款 ro),各 skill 调 `render.py` 不再自带 render 脚本。 +- `rendering/`:`common.py`(叶子原语单一事实源:字体/`CHEM_RE`/块级正则/表格行/图片路径)+ `docx_manuscript.py`(paper/proposal 配置化双 profile)+ `docx_brief.py`(brief 富渲染,复用 common)+ `pdf.py`(md→HTML→chromium `--print-to-pdf`,复用 `common.CHEM_RE`)+ `render.py`(统一 CLI `--profile {brief,paper,proposal} --format {docx,pdf}`,sys.path bootstrap 让 `python /sandbox/rendering/render.py` 直调可解析)。 +- **零回归证明**:重构前后对三 profile 各渲 docx、解包 diff `word/document.xml`,brief/paper/proposal **全部字节一致**(12962/10755/11401 bytes)。纯搬移+共享原语,输出不变。 +- chromium md→pdf:不用 weasyprint(要 pango/cairo、不在仓库 Dockerfile);chromium 镜像已装(给 mermaid)+ fonts-noto-cjk 已装,完整内核 CSS 保真度更高。固定 `--no-sandbox --disable-dev-shm-usage --user-data-dir=/tmp/* --no-pdf-header-footer`。冒烟 `deploy/sandbox/probe_chromium_pdf.sh`(照 probe_mermaid.sh):最小 chromium 镜像在 `--read-only --cap-drop=ALL` + 64MB `/dev/shm` 下实测出图,中文/下标/DOI 超链/表格/callout 全绿、页眉已关。 +- 删:`skills/{brief,paper,proposal}/scripts/render_docx.py`(3 份)+ 短命的 `skills/_shared/render_pdf.py`。改 5 个 SKILL.md(brief/paper/proposal 直接调,patent/standard 复用 proposal profile)调用到 render.py + 补反模式"渲染一律调 render.py、禁止手搓"。`requirements.txt` 加 `markdown`。 +- **部署要点**:`/sandbox/rendering` 挂载靠 pool.py(restart 重建容器才生效)+ `markdown` 进镜像靠 requirements 变更触发的整体重建 —— **需一次 deploy(update.sh)原子激活**,旧 render_docx 路径已删,deploy 前别只推 SKILL 改动。引文 `[n]` 上标回链 pdf 仍按字面渲(docx 有,pdf 后补)。 +- 文件:`rendering/{__init__,common,docx_manuscript,docx_brief,pdf,render}.py`(新)、`core/sandbox/pool.py`(+rendering 挂载)、`deploy/sandbox/probe_chromium_pdf.sh`(新)、`requirements.txt`、5×`SKILL.md`、`skills/brief/SKILL.md`(另删 research 索引滞后描述)、`core/__init__.py` 0.20.4→0.21.0。 + ### 2026-06-23 / 消息目录定位错位修复(bump 0.20.4) - 现象:点右侧圆点轨道**第一个**圆点,活跃高亮常落到**第二个**。根因是两套锚点不一致——`jumpToMessage` 用 `block:"center"` 居中,但第一轮上方无内容无法居中、被钉到顶端;而 `updateActiveOutlineDot` 按「顶线 80px 容差」判活跃轮,第一轮短时下一轮卡片顶也落进 80px 带内 → 越界高亮第二个圆点(滚动监听又覆盖了 jumpToMessage 的显式 setActiveOutlineIdx)。 diff --git a/RUN.md b/RUN.md index 4effd6e..774f697 100644 --- a/RUN.md +++ b/RUN.md @@ -301,6 +301,7 @@ sudo bash /opt/zcbot/deploy/update.sh 脚本顺序写死:`git pull --ff-only` → `pip install -r` → `db upgrade head` → **`docker build` sandbox 镜像** → **`systemctl restart zcbot`** → `curl /healthz` 验活。要点: - **build 必须在 restart 之前**:sandbox 容器 per-user 长驻 + 复用,`tools/` 是 build 进镜像的(非 mount)。restart 时 `shutdown_all` 清旧容器,下次 `ensure()` 才用新 `zcbot-sandbox:latest` 重建 —— 顺序反了新 tools/ 要等下次重启才生效。 +- **平台渲染层 `rendering/`(2026-06-23 起)**:各 skill 出 docx/pdf 调 `python /sandbox/rendering/render.py --profile {brief,paper,proposal} --format {docx,pdf}`(不再各自带 render_docx.py)。`rendering/` 随 `pool.py` **bind-mount 进 `/sandbox/rendering`**(restart 重建容器才挂上),pdf 依赖 `markdown`(已进 requirements,镜像重建才内置)+ 镜像自带 chromium。**这次升级要整体重建镜像 + restart 一并 deploy**——旧 render_docx 路径已删,只推代码不重建会让 brief/paper/proposal/patent/standard 渲染失败。沙盒 chromium 渲 pdf 的冒烟探针:`deploy/sandbox/probe_chromium_pdf.sh`(服务器上跑,用法见脚本头)。 - **sandbox build 每次都跑没关系**:layer cache 让重活(pip ~1G / chromium / 字体 / mermaid,都在 `COPY tools/` 之上)在改代码部署时秒过;只有 `requirements.txt` 变了才整体重建(~5-10min,正好也是该重建的时候)。host backend 机器 / 临时不想动 docker:`sudo bash deploy/update.sh --skip-build`。 - **镜像源默认:pip+apt 清华、npm 腾讯**(`PIP_INDEX_URL=pypi.tuna.tsinghua.edu.cn/simple/` / `APT_MIRROR=mirrors.tuna.tsinghua.edu.cn` / `NPM_REGISTRY=mirrors.cloud.tencent.com/npm/`)。pip 选清华是因为**腾讯 PyPI 曾返回损坏的 litellm wheel**(index hash 对、文件字节不对 → pip `DO NOT MATCH THE HASHES`),且**阿里 PyPI 又一度滞后**(litellm 只到 1.82.6,卡死 `>=1.83.0`);清华境内稳 + 同步及时。npm 用腾讯是因为**清华不提供 npm registry**、npmmirror 访问不稳,腾讯 npm 历来 OK(坏 wheel 只是腾讯 PyPI 的事,npm 不受影响;备选华为 / USTC npm 源)。要命中 docker cache 就别多组源来回换(换源从 pip 层炸开全重跑)。想用官方源:`PIP_INDEX_URL= sudo -E bash deploy/update.sh`(置空即回落 Dockerfile 官方默认)。host venv 的 step 2 pip 也吃这个源(脚本显式 `--index-url`,不靠 host pip.conf)。 - **进度可见**:step 2 pip 不带 `-q`,部署时能看到装包进度;step 4 docker build 走默认 TTY 进度 UI(分层折叠刷新,直观)。 diff --git a/SKILL_LIST.md b/SKILL_LIST.md index 1432a30..3b10c62 100644 --- a/SKILL_LIST.md +++ b/SKILL_LIST.md @@ -57,7 +57,7 @@ zcbot 的"skill"是一份可加载的工作流脚本(`skills//SKILL.md` + - **引文三角核验**(`citation_verify.md`,移植 ARS 思路、后端换成自有 documents/research 库):存在性 → 三角印证 → 支撑度(抓原文比对 ≤25 词锚点,partial 就改论断迁就证据),编造引文零容忍 - "先定图表再写正文"纪律(接 plot_pub 出 figure)+ 文献矩阵立证据底座 - 写作顺序 Methods→Results→Intro→Discussion→Abstract→Title;关键章一段一卡 + 预告下一段 -- `quality_check.py`:结构 / 占位符 / 过度宣称 + **引文交叉核对**(orphan / uncited / 编号连续);`render_docx.py` 中英字体切换 + 图题自增;`word_count.py` 按类型 × 语言核篇幅 +- `quality_check.py`:结构 / 占位符 / 过度宣称 + **引文交叉核对**(orphan / uncited / 编号连续);docx/pdf 调平台渲染层 `rendering/render.py --profile paper`(中英字体切换 + 图题自增);`word_count.py` 按类型 × 语言核篇幅 - 终审复用 review skill 的反谄媚审稿协议;可选出 cover letter / AI 声明 / CRediT **典型产物**:`.docx`(投稿稿)+ sections/ 分章草稿 + `lit_matrix.md`(文献矩阵)+ `CITATIONS.md`(引文核验台账)。 @@ -314,7 +314,7 @@ paper_server 是内部 Django 文献库:元数据来自 OpenAlex,PDF / XML 由 S - **三路分工 + 去重**:research+documents 取文献(同 DOI 一条、documents 全文优先)、web 单列产业政策动向不混论文总结;中文方向→英文术语转译(SCM/LC3 等缩写展开) - **每篇带摘要概述**:列表不只标题,每篇 2–4 句讲研究对象/方法/主要发现,基于 abstract 或全文、不夸张不评判 - **引文核验**:存在性 / DOI 真伪(以库返回字段为准)/ 支撑度(摘要概述与原文一致,partial 改概述迁就证据),编造零容忍 -- **自带 `render_docx.py`**:商务红主题 + 论文列表 `[n]` 作锚点、正文 `[n]`/`[Wn]` 引文上标回链 + DOI/URL 可点击超链接(条目内 DOI 子串也链)+ 化学式下标(CO₂/C₃S...,白名单不误伤 LC3/Ca2+);做 deck 转 ppt +- **平台渲染层 `rendering/render.py --profile brief`**(docx/pdf):商务红主题 + 论文列表 `[n]` 作锚点、正文 `[n]`/`[Wn]` 引文上标回链 + DOI/URL 可点击超链接(条目内 DOI 子串也链)+ 化学式下标(CO₂/C₃S...,白名单不误伤 LC3/Ca2+);pdf 走沙盒 chromium;做 deck 转 ppt **典型产物**:`<方向>-简报.md`(默认,含 `01_papers` 重要论文列表 + `02_summary` 内容总结)+ `evidence.md`(证据表);可选转 docx / deck。 diff --git a/core/__init__.py b/core/__init__.py index 521493c..d75f543 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.20.4" +__version__ = "0.21.0" diff --git a/core/sandbox/pool.py b/core/sandbox/pool.py index b09973d..12b9a51 100644 --- a/core/sandbox/pool.py +++ b/core/sandbox/pool.py @@ -236,6 +236,11 @@ class SandboxPool: skills_path = (self.repo_root / "skills").resolve() if skills_path.is_dir(): cmd += ["-v", f"{skills_path}:/sandbox/skills:ro"] + # 平台渲染层(rendering/)只读 mount ── 各 skill 出 docx/pdf 调 + # `python /sandbox/rendering/render.py`,不再自带 render 脚本。与 skills 同款 ro。 + rendering_path = (self.repo_root / "rendering").resolve() + if rendering_path.is_dir(): + cmd += ["-v", f"{rendering_path}:/sandbox/rendering:ro"] if self.runtime: cmd += ["--runtime", self.runtime] cmd.append(self.image) diff --git a/deploy/sandbox/probe_chromium_pdf.sh b/deploy/sandbox/probe_chromium_pdf.sh new file mode 100644 index 0000000..4e2730b --- /dev/null +++ b/deploy/sandbox/probe_chromium_pdf.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# 在 sandbox 容器里实测 `chromium --headless --print-to-pdf`(md→HTML→PDF 的 PDF 那段)。 +# 区分「chromium 缺包」「纯启动超时(/dev/shm 64MB)」「只读 rootfs 下 user-data-dir 写不了」。 +# 用法(服务器上,任选其一): +# A) 进一个活着的 per-user 容器(最贴真,复用线上 64MB /dev/shm 默认): +# C=$(docker ps --filter "label=zcbot.product=sandbox" --format '{{.Names}}' | head -1) +# docker cp deploy/sandbox/probe_chromium_pdf.sh "$C":/tmp/probe.sh +# docker exec "$C" bash /tmp/probe.sh +# B) 没有活容器时,起一个临时的(显式 NOT 传 --shm-size,复现线上 64MB): +# docker run --rm --read-only --tmpfs /tmp:exec,size=512m,mode=1777 \ +# --cap-drop=ALL --security-opt=no-new-privileges \ +# --entrypoint bash zcbot-sandbox:latest /dev/stdin < deploy/sandbox/probe_chromium_pdf.sh +set -u + +CR="" +for c in chromium chromium-browser /usr/bin/chromium; do + command -v "$c" >/dev/null 2>&1 && { CR="$c"; break; } +done + +echo "===== /dev/shm size (期望线上 64M) ====="; df -h /dev/shm +echo "===== chromium 是否在 (缺包则这里就失败) =====" +[ -n "$CR" ] && "$CR" --version 2>&1 | head -1 || { echo "[FAIL] chromium 缺包/不可执行"; exit 1; } + +# 测试输入:中文 + 表格背景色(print-color-adjust) + 化学式下标 + 超链接,覆盖简报常见元素 +cd /tmp +cat > in.html <<'HTML' + +

水泥科研方向 — 冒烟测试

+

中文渲染、化学式 CO2 / C3S、DOI 超链接

+
期刊篇数
Cement and Concrete Research11
+ +HTML + +run() { # $1=label $2..=extra flags + local label="$1"; shift + local ts=$SECONDS + timeout 60 "$CR" --headless --disable-gpu --no-sandbox \ + --user-data-dir=/tmp/cr-$label "$@" \ + --print-to-pdf=/tmp/out-$label.pdf /tmp/in.html >"$label.log" 2>&1 + local rc=$? + echo "rc=$rc 用时=$((SECONDS-ts))s"; tail -3 "$label.log" + if [ -s "/tmp/out-$label.pdf" ]; then + echo "[$label 出图] $(wc -c < /tmp/out-$label.pdf) bytes -> /tmp/out-$label.pdf" + else + echo "[$label 无图]" + fi +} + +echo; echo "===== A: 漏 --disable-dev-shm-usage(线上 64MB /dev/shm)→ 可能挂起/超时 =====" +run A + +echo; echo "===== B: 加 --disable-dev-shm-usage(走 /tmp)→ 预期成功出 PDF =====" +run B --disable-dev-shm-usage + +echo; echo "===== 结论 =====" +echo "B 出图 => chromium print-to-pdf 可用,render_pdf.py 固定带 --disable-dev-shm-usage + --user-data-dir=/tmp/* 即可" +echo "B 无图/超时 => 看 B.log;若是 /dev/shm 仍报错,给 docker run 加 --shm-size" +echo "chromium 缺/全失败 => 更深环境问题,镜像没装好 chromium/字体" diff --git a/rendering/__init__.py b/rendering/__init__.py new file mode 100644 index 0000000..5a64dfd --- /dev/null +++ b/rendering/__init__.py @@ -0,0 +1,11 @@ +"""平台渲染层:把 sections/*.md(或单 .md)渲染成 docx / pdf。 + +不是 skill 内容,是**平台能力**——各 skill 通过 `render.py` CLI 调用,自身不再 bundle +渲染脚本(故 fork skill 不受影响)。随镜像 bind-mount 进 `/sandbox/rendering`。 + +- common.py 叶子原语(字体/化学式白名单/块级正则/表格行切分/图片路径),三 profile 单一事实源 +- docx_manuscript.py paper 投稿稿 + proposal 申报书(配置化双 profile) +- docx_brief.py brief 简报(商务红 + 引文上标超链 + callout) +- pdf.py md→HTML→沙盒 chromium --print-to-pdf +- render.py 统一入口:--profile {brief,paper,proposal} --format {docx,pdf} +""" diff --git a/rendering/common.py b/rendering/common.py new file mode 100644 index 0000000..37de5be --- /dev/null +++ b/rendering/common.py @@ -0,0 +1,143 @@ +"""平台渲染层 · 共享叶子原语(docx 三 profile + 部分 pdf 复用)。 + +放**真正同源、与 profile 无关**的底层件:字体 OOXML 助手、化学式下标白名单、 +内联/块级 markdown 正则、表格行切分、图片路径解析。三套 docx profile +(manuscript=paper/proposal、brief)都 import 这里,**单一事实源**—— +改化学式白名单 / 字体规范只动这一处,不再三处各拷一份。 + +历史:原先 skills/{brief,paper,proposal}/scripts/render_docx.py 各自带一份 +拷贝(_CHEM_RE 三份逐字相同、易漏改)。2026-06 抽到平台层 rendering/。 +""" +from __future__ import annotations + +import re +from pathlib import Path + +from docx.oxml import OxmlElement +from docx.oxml.ns import qn +from docx.shared import Cm, Pt + + +# ───────────────────────── 字体 OOXML 助手 ───────────────────────── + +def set_run_fonts(run, *, cn_font: str = "宋体", en_font: str = "Times New Roman") -> None: + """同时设置 run 的中文 (eastAsia) 和西文 (ascii/hAnsi) 字体。""" + rPr = run._element.get_or_add_rPr() + rFonts = rPr.find(qn("w:rFonts")) + if rFonts is None: + rFonts = OxmlElement("w:rFonts") + rPr.append(rFonts) + rFonts.set(qn("w:eastAsia"), cn_font) + rFonts.set(qn("w:ascii"), en_font) + rFonts.set(qn("w:hAnsi"), en_font) + + +def set_style_fonts(style, *, cn_font: str = "宋体", en_font: str = "Times New Roman") -> None: + """直接给 style 写 rFonts, 基于该 style 的所有段落都继承字体。""" + el = style.element + rPr = el.find(qn("w:rPr")) + if rPr is None: + rPr = OxmlElement("w:rPr") + el.insert(0, rPr) + rFonts = rPr.find(qn("w:rFonts")) + if rFonts is None: + rFonts = OxmlElement("w:rFonts") + rPr.append(rFonts) + rFonts.set(qn("w:eastAsia"), cn_font) + rFonts.set(qn("w:ascii"), en_font) + rFonts.set(qn("w:hAnsi"), en_font) + + +def set_subscript(run) -> None: + rPr = run._element.get_or_add_rPr() + va = OxmlElement("w:vertAlign") + va.set(qn("w:val"), "subscript") + rPr.append(va) + + +# ───────────────────────── 内联 markdown 切分 ───────────────────────── + +# 顺序敏感:**bold** 必须先于 *italic* 匹配, 否则会被 italic 抢 +INLINE_RE = re.compile( + r"(?P\*\*(?P[^*\n]+?)\*\*)" + r"|(?P(?[^*\n]+?)\*(?!\*))" + r"|(?P`(?P[^`\n]+?)`)" +) + + +def parse_inline(text: str) -> list[tuple[str, str]]: + """切成 (style, segment) 列表; style ∈ plain/bold/italic/code。""" + out: list[tuple[str, str]] = [] + pos = 0 + for m in INLINE_RE.finditer(text): + if m.start() > pos: + out.append(("plain", text[pos:m.start()])) + if m.group("bold"): + out.append(("bold", m.group("bold_t"))) + elif m.group("italic"): + out.append(("italic", m.group("italic_t"))) + elif m.group("code"): + out.append(("code", m.group("code_t"))) + pos = m.end() + if pos < len(text): + out.append(("plain", text[pos:])) + return out or [("plain", text)] + + +# ── 化学式下标白名单(三 profile 共用同一份;单一事实源)── +# 长的在前,\b 防误伤 LC3 / C595 / 2026;不收 Ca2+ 这类带电荷的(那是上标,白名单不收即天然避开) +CHEM_RE = re.compile( + r"Ca\(OH\)2|Mg\(OH\)2" + r"|\b(?:Al2O3|Fe2O3|Fe3O4|Mn2O3|Cr2O3|P2O5|Na2SO4|K2SO4|CaSO4|CaCO3|MgCO3|" + r"CaCl2|MgCl2|Na2O|K2O|SiO2|TiO2|ZrO2|SO4|SO3|SO2|CO3|CO2|NO3|NO2|PO4|" + r"H2O|NH3|CH4|C4AF|C3S2|C2AS|C3S|C2S|C3A|O2|N2|H2)\b" +) + + +# ───────────────────────── 块级行类型正则 ───────────────────────── + +HEADING_RE = re.compile(r"^(#{1,6})\s+(.+)$") +TABLE_LINE_RE = re.compile(r"^\s*\|.*\|\s*$") +BLOCKQUOTE_RE = re.compile(r"^\s*>\s?") +HR_RE = re.compile(r"^\s*-{3,}\s*$|^\s*={3,}\s*$|^\s*_{3,}\s*$") +FENCE_RE = re.compile(r"^\s*(`{3,}|~{3,})\s*(\S*)\s*$") +IMAGE_LINE_RE = re.compile(r"^\s*!\[(?P[^\]]*)\]\((?P[^)\s]+)\)\s*$") + + +def is_table_line(line: str) -> bool: + return bool(TABLE_LINE_RE.match(line)) + + +def is_heading(line: str) -> bool: + return bool(HEADING_RE.match(line)) + + +def is_blockquote(line: str) -> bool: + return bool(BLOCKQUOTE_RE.match(line)) + + +def is_hr(line: str) -> bool: + return bool(HR_RE.match(line)) + + +# ───────────────────────── 表格行切分 ───────────────────────── + +def split_md_row(line: str) -> list[str]: + return [c.strip() for c in line.strip().strip("|").split("|")] + + +def is_separator_row(cells: list[str]) -> bool: + return all(re.match(r"^[-:\s]+$", c) for c in cells if c != "") + + +# ───────────────────────── 图片 ───────────────────────── + +MAX_IMG_WIDTH = Cm(15) + + +def resolve_image_path(src: str, base_dir: Path) -> Path | None: + """图片相对路径以 base_dir (单个 .md 所在目录) 为锚。""" + p = Path(src) + if not p.is_absolute(): + p = (base_dir / p).resolve() + return p if p.is_file() else None diff --git a/skills/brief/scripts/render_docx.py b/rendering/docx_brief.py similarity index 83% rename from skills/brief/scripts/render_docx.py rename to rendering/docx_brief.py index 7ad26e8..cd251ac 100644 --- a/skills/brief/scripts/render_docx.py +++ b/rendering/docx_brief.py @@ -1,21 +1,12 @@ -"""把 sections/*.md 渲染成科研方向简报 .docx(简报体例,区别于 paper 的投稿稿)。 +"""brief 简报体例 docx 渲染器(商务红主题 + 引文上标超链 + callout/底纹边框)。 -相对 paper/render_docx.py 的简报专属增强: -- **商务红配色**(主色 #C00000):标题分级染色 + 标题下细色条;TL;DR / 「判断」行做浅红底纹 callout -- **引文上标 + 内部超链接**:正文 [1] / [W3] → 上标红色,点击锚到「重要论文列表 / 参考文献」段对应条目 -- **论文列表 / 参考文献可点击**:标题含「论文列表 / 文献列表 / 参考文献」的段,行首 [n] 条目作锚点; - 条目内 DOI(整条是 DOI 或末尾 "DOI: 10.xxx")→ https://doi.org/... 蓝色超链接;web 条目里的域名/路径 → https:// 超链接 -- **化学式下标(白名单)**:CO2 / C3S2 / Na2O / SO4 ... → 真实下标,**白名单精确匹配**,不误伤 LC3 / EN 197-5 / 8.5 Mt / 2026 - -字体规范同院内其它渲染:中文宋体小四 / 英文 Times New Roman 小四 / 行距 1.5 / 首行缩进 2 字符。 -支持 **加粗** / *斜体* / `等宽` / 列表 / 表格 / ![caption](png) 居中插图。 - -用法: - python render_docx.py -o - python render_docx.py --no-color -o # 关配色出纯黑白 +brief 是三 profile 里最富的一支:书签锚点、内部/外部超链接、引文 [n]/[Wn] 上标回链、 +参考条目 DOI 超链、概览信息带 / TL;DR 卡片 / 判断 callout、页脚页码域。这些 paper/proposal +都没有,故 brief 保留自己的渲染层,只从 rendering.common 复用叶子原语(字体/化学式/块级正则/ +表格行切分/图片路径)。函数体逐字移植自旧 skills/brief/scripts/render_docx.py。 """ from __future__ import annotations -import argparse + import re import sys from pathlib import Path @@ -27,6 +18,24 @@ from docx.oxml import OxmlElement from docx.oxml.ns import qn from docx.shared import Cm, Pt, RGBColor +from .common import ( + set_run_fonts as _set_run_fonts, + set_style_fonts as _set_style_fonts, + set_subscript as _set_subscript, + CHEM_RE as _CHEM_RE, + INLINE_RE as _INLINE_RE, + HEADING_RE as _HEADING_RE, + TABLE_LINE_RE as _TABLE_LINE_RE, + BLOCKQUOTE_RE as _BLOCKQUOTE_RE, + HR_RE as _HR_RE, + FENCE_RE as _FENCE_RE, + IMAGE_LINE_RE as _IMAGE_LINE_RE, + split_md_row as _split_md_row, + is_separator_row as _is_sep_row, + resolve_image_path as _resolve_image_path, + MAX_IMG_WIDTH as _MAX_IMG_WIDTH, +) + # ───────────────────────── 主题色 ───────────────────────── PRIMARY = "C00000" # 商务红主色 @@ -37,40 +46,7 @@ LINK_BLUE = "1155CC" # 超链接蓝 TABLE_HEAD_FILL = "C00000" -# ───────────────────────── 字体 / 低层 OOXML 辅助 ───────────────────────── - -def _set_run_fonts(run, *, cn_font="宋体", en_font="Times New Roman") -> None: - rPr = run._element.get_or_add_rPr() - rFonts = rPr.find(qn("w:rFonts")) - if rFonts is None: - rFonts = OxmlElement("w:rFonts") - rPr.append(rFonts) - rFonts.set(qn("w:eastAsia"), cn_font) - rFonts.set(qn("w:ascii"), en_font) - rFonts.set(qn("w:hAnsi"), en_font) - - -def _set_style_fonts(style, *, cn_font="宋体", en_font="Times New Roman") -> None: - el = style.element - rPr = el.find(qn("w:rPr")) - if rPr is None: - rPr = OxmlElement("w:rPr") - el.insert(0, rPr) - rFonts = rPr.find(qn("w:rFonts")) - if rFonts is None: - rFonts = OxmlElement("w:rFonts") - rPr.append(rFonts) - rFonts.set(qn("w:eastAsia"), cn_font) - rFonts.set(qn("w:ascii"), en_font) - rFonts.set(qn("w:hAnsi"), en_font) - - -def _set_subscript(run) -> None: - rPr = run._element.get_or_add_rPr() - va = OxmlElement("w:vertAlign") - va.set(qn("w:val"), "subscript") - rPr.append(va) - +# ───────────────────────── 低层 OOXML 辅助 ───────────────────────── def _para_shading(paragraph, fill: str) -> None: pPr = paragraph._p.get_or_add_pPr() @@ -191,26 +167,11 @@ def init_doc(color: bool) -> Document: return doc -# ───────────────────────── 内联:bold/italic/code 切分 ───────────────────────── - -_INLINE_RE = re.compile( - r"(?P\*\*(?P[^*\n]+?)\*\*)" - r"|(?P(?[^*\n]+?)\*(?!\*))" - r"|(?P`(?P[^`\n]+?)`)" -) +# ───────────────────────── 内联:bold/italic/code + 引文 + 化学式 ───────────────────────── # 引文标记 [12] / [W3] _CITE_RE = re.compile(r"\[(W?\d+)\]") -# 化学式下标白名单(统一三处渲染器共用同一份;长的在前,\b 防误伤 LC3 / C595 / 2026; -# 不含 Ca2+ 这类带电荷的——它是上标不是下标,白名单不收即天然避开) -_CHEM_RE = re.compile( - r"Ca\(OH\)2|Mg\(OH\)2" - r"|\b(?:Al2O3|Fe2O3|Fe3O4|Mn2O3|Cr2O3|P2O5|Na2SO4|K2SO4|CaSO4|CaCO3|MgCO3|" - r"CaCl2|MgCl2|Na2O|K2O|SiO2|TiO2|ZrO2|SO4|SO3|SO2|CO3|CO2|NO3|NO2|PO4|" - r"H2O|NH3|CH4|C4AF|C3S2|C2AS|C3S|C2S|C3A|O2|N2|H2)\b" -) - def _emit_chem(paragraph, text: str, *, size_pt: float, cn_font: str) -> None: """把白名单化学式里的数字渲成下标,其余正常。""" @@ -455,15 +416,7 @@ def add_reference_item(doc: Document, cid: str, value: str, bm_id: int, color: b _emit_plain_run(p, value, size_pt=10.5, cn_font="宋体") -# ───────────────────────── 行类型识别 ───────────────────────── - -_HEADING_RE = re.compile(r"^(#{1,6})\s+(.+)$") -_TABLE_LINE_RE = re.compile(r"^\s*\|.*\|\s*$") -_BLOCKQUOTE_RE = re.compile(r"^\s*>\s?") -_HR_RE = re.compile(r"^\s*-{3,}\s*$|^\s*={3,}\s*$|^\s*_{3,}\s*$") -_FENCE_RE = re.compile(r"^\s*(`{3,}|~{3,})\s*(\S*)\s*$") -_IMAGE_LINE_RE = re.compile(r"^\s*!\[(?P[^\]]*)\]\((?P[^)\s]+)\)\s*$") -_MAX_IMG_WIDTH = Cm(15) +# ───────────────────────── 行类型识别(brief 专属列表模式)───────────────────────── _LIST_PATTERNS = [ re.compile(r"^[-*+]\s"), @@ -480,14 +433,6 @@ def is_list_item(line: str) -> bool: # ───────────────────────── 表格 ───────────────────────── -def _split_md_row(line: str) -> list[str]: - return [c.strip() for c in line.strip().strip("|").split("|")] - - -def _is_sep_row(cells: list[str]) -> bool: - return all(re.match(r"^[-:\s]+$", c) for c in cells if c != "") - - def render_table(doc: Document, table_lines: list[str], color: bool) -> None: rows = [] for ln in table_lines: @@ -525,13 +470,6 @@ def render_table(doc: Document, table_lines: list[str], color: bool) -> None: # ───────────────────────── 图片 ───────────────────────── -def _resolve_image_path(src: str, base_dir: Path) -> Path | None: - p = Path(src) - if not p.is_absolute(): - p = (base_dir / p).resolve() - return p if p.is_file() else None - - def add_image(doc: Document, png_path: Path, caption: str | None, ctx: dict) -> None: p = doc.add_paragraph() p.alignment = WD_ALIGN_PARAGRAPH.CENTER @@ -683,6 +621,8 @@ def render_md_block(doc: Document, md_text: str, ctx: dict) -> None: i = j +# ───────────────────────── 入口 ───────────────────────── + def render_sections(sections_dir: Path, out: Path, color: bool) -> None: if not sections_dir.is_dir(): print(f"[ERR] sections dir not found: {sections_dir}", file=sys.stderr) @@ -712,19 +652,5 @@ def render_sections(sections_dir: Path, out: Path, color: bool) -> None: paras = sum(1 for _ in doc.paragraphs) chars = sum(len(p.text) for p in doc.paragraphs) print(f"[OK] rendered {len(md_files)} sections -> {out}") - print(f" paragraphs: {paras} | tables: {len(doc.tables)} | figures: {ctx['fig_no']} | chars: {chars}") - print(f" theme: {'商务红 #C00000' if color else '黑白'} | 引文上标+超链接 | 化学式下标白名单") - - -def main() -> None: - ap = argparse.ArgumentParser(description="渲染章节 md → 科研方向简报 docx") - ap.add_argument("sections_dir", type=Path, help="sections/*.md 目录") - ap.add_argument("--no-color", dest="color", action="store_false", - help="关配色,出纯黑白(默认商务红主题)") - ap.add_argument("-o", "--output", type=Path, required=True, help="输出 .docx 路径") - args = ap.parse_args() - render_sections(args.sections_dir, args.output, args.color) - - -if __name__ == "__main__": - main() + print(f" profile: brief | paragraphs: {paras} | tables: {len(doc.tables)} | " + f"figures: {ctx['fig_no']} | chars: {chars} | theme: {'商务红' if color else '黑白'}") diff --git a/skills/paper/scripts/render_docx.py b/rendering/docx_manuscript.py similarity index 58% rename from skills/paper/scripts/render_docx.py rename to rendering/docx_manuscript.py index 177da91..917df0d 100644 --- a/skills/paper/scripts/render_docx.py +++ b/rendering/docx_manuscript.py @@ -1,20 +1,14 @@ -"""把 sections/*.md 渲染成期刊投稿稿 .docx (manuscript draft)。 +"""manuscript 体例 docx 渲染器(paper 投稿稿 + proposal 申报书,配置化双 profile)。 -与 proposal/render_docx.py 同源, 差异: -- 无 fund-type; 改用 --lang {zh,en} (默认 en) 标注语言, 仅影响信息打印与首行缩进策略 -- 目录 (TOC) 默认**不生成** (期刊投稿稿无需目录); 要草稿带目录加 --toc -- 字体规范保持: 中文宋体小四 / 英文 Times New Roman 小四 / 行距 1.5 / 首行缩进 2 字符 - (eastAsia=宋体 只对 CJK 字符生效, 纯英文论文正文走 Times New Roman, 同一套 style 通吃) +两者原是近亲(~80% 逐字相同),差异收进 PROFILES:页边距 / TOC 标题 / 图题前缀 / +列表多一条"第X条" / sections 循环(toc 是否默认 + 末段是否补分页)。函数体移植自 +旧 paper/proposal render_docx.py,叶子原语走 rendering.common。 -支持: **加粗** / *斜体* / `等宽`; 列表 / 表格 / ![caption](png) 居中插图 + 图题自增; -```mermaid``` 块按 caption 查 figures/fig_.png (由 render_diagrams.py 预生成)。 - -用法: - python render_docx.py --lang en -o - python render_docx.py --lang zh --toc -o +profile=paper: --lang {zh,en}(图题前缀 图/Fig.),--toc 可选(默认无) +profile=proposal: --fund-type ...(仅打印),始终带 TOC,每段后分页 """ from __future__ import annotations -import argparse + import re import sys from pathlib import Path @@ -25,38 +19,50 @@ from docx.oxml import OxmlElement from docx.oxml.ns import qn from docx.shared import Cm, Pt, RGBColor - -# ───────────────────────── 字体辅助 ───────────────────────── - -def _set_run_fonts(run, *, cn_font: str = "宋体", en_font: str = "Times New Roman") -> None: - rPr = run._element.get_or_add_rPr() - rFonts = rPr.find(qn("w:rFonts")) - if rFonts is None: - rFonts = OxmlElement("w:rFonts") - rPr.append(rFonts) - rFonts.set(qn("w:eastAsia"), cn_font) - rFonts.set(qn("w:ascii"), en_font) - rFonts.set(qn("w:hAnsi"), en_font) +from . import common +from .common import set_run_fonts, set_style_fonts, set_subscript, CHEM_RE, parse_inline -def _set_style_fonts(style, *, cn_font: str = "宋体", en_font: str = "Times New Roman") -> None: - el = style.element - rPr = el.find(qn("w:rPr")) - if rPr is None: - rPr = OxmlElement("w:rPr") - el.insert(0, rPr) - rFonts = rPr.find(qn("w:rFonts")) - if rFonts is None: - rFonts = OxmlElement("w:rFonts") - rPr.append(rFonts) - rFonts.set(qn("w:eastAsia"), cn_font) - rFonts.set(qn("w:ascii"), en_font) - rFonts.set(qn("w:hAnsi"), en_font) +# ───────────────────────── profile 配置 ───────────────────────── + +_BASE_LIST_PATTERNS = [ + re.compile(r"^\[\d+\]\s"), # [1] + re.compile(r"^[-*+]\s"), # - / * / + + re.compile(r"^\d+[\.、.]\s*"), # 1. / 1、 / 1. + re.compile(r"^\(\d+\)\s*"), # (1) + re.compile(r"^(\d+)\s*"), # (1) + re.compile(r"^[一二三四五六七八九十百千]+[、.\.]"), # 一、 + re.compile(r"^[((][一二三四五六七八九十百千]+[))]"), # (一) + re.compile(r"^[①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮]"), # ① +] + +PROFILES = { + "paper": { + "left_margin": Cm(2.5), + "right_margin": Cm(2.5), + "list_patterns": _BASE_LIST_PATTERNS, + "toc_title": "Contents", + "toc_placeholder": "[Press F9 in Word to generate the table of contents]", + "always_toc": False, + "trailing_page_break": False, + }, + "proposal": { + "left_margin": Cm(3.0), + "right_margin": Cm(2.0), + "list_patterns": _BASE_LIST_PATTERNS + [ + re.compile(r"^第[一二三四五六七八九十百]+[条章节]"), # 第一条 + ], + "toc_title": "目 录", + "toc_placeholder": "[在 Word 中按 F9 或右键此处选择 “更新域” 即可生成完整目录]", + "always_toc": True, + "trailing_page_break": True, + }, +} # ───────────────────────── 文档初始化 ───────────────────────── -def init_doc() -> Document: +def init_doc(prof: dict) -> Document: doc = Document() section = doc.sections[0] @@ -64,13 +70,13 @@ def init_doc() -> Document: section.page_width = Cm(21) section.top_margin = Cm(2.5) section.bottom_margin = Cm(2.5) - section.left_margin = Cm(2.5) - section.right_margin = Cm(2.5) + section.left_margin = prof["left_margin"] + section.right_margin = prof["right_margin"] normal = doc.styles["Normal"] normal.font.name = "Times New Roman" normal.font.size = Pt(12) - _set_style_fonts(normal, cn_font="宋体") + set_style_fonts(normal, cn_font="宋体") pf = normal.paragraph_format pf.line_spacing = 1.5 pf.space_before = Pt(0) @@ -82,7 +88,7 @@ def init_doc() -> Document: h.font.size = sz h.font.bold = True h.font.color.rgb = RGBColor(0, 0, 0) - _set_style_fonts(h, cn_font=cn) + set_style_fonts(h, cn_font=cn) h.paragraph_format.line_spacing = 1.5 h.paragraph_format.space_before = Pt(6) h.paragraph_format.space_after = Pt(3) @@ -91,18 +97,16 @@ def init_doc() -> Document: return doc -# ───────────────────────── TOC (opt-in) ───────────────────────── - -def add_toc(doc: Document, depth: int = 3) -> None: +def add_toc(doc: Document, prof: dict, depth: int = 3) -> None: p = doc.add_paragraph() p.alignment = WD_ALIGN_PARAGRAPH.CENTER p.paragraph_format.first_line_indent = None p.paragraph_format.space_before = Pt(12) p.paragraph_format.space_after = Pt(6) - run = p.add_run("Contents") + run = p.add_run(prof["toc_title"]) run.font.size = Pt(16) run.font.bold = True - _set_run_fonts(run, cn_font="黑体") + set_run_fonts(run, cn_font="黑体") p = doc.add_paragraph() p.paragraph_format.first_line_indent = None @@ -119,7 +123,7 @@ def add_toc(doc: Document, depth: int = 3) -> None: fldChar3.set(qn("w:fldCharType"), "end") placeholder_t = OxmlElement("w:t") placeholder_t.set(qn("xml:space"), "preserve") - placeholder_t.text = "[Press F9 in Word to generate the table of contents]" + placeholder_t.text = prof["toc_placeholder"] run._element.append(fldChar1) run._element.append(instrText) run._element.append(fldChar2) @@ -128,49 +132,7 @@ def add_toc(doc: Document, depth: int = 3) -> None: doc.add_page_break() -# ───────────────────────── 内联 markdown ───────────────────────── - -_INLINE_RE = re.compile( - r"(?P\*\*(?P[^*\n]+?)\*\*)" - r"|(?P(?[^*\n]+?)\*(?!\*))" - r"|(?P`(?P[^`\n]+?)`)" -) - - -def parse_inline(text: str) -> list[tuple[str, str]]: - out: list[tuple[str, str]] = [] - pos = 0 - for m in _INLINE_RE.finditer(text): - if m.start() > pos: - out.append(("plain", text[pos:m.start()])) - if m.group("bold"): - out.append(("bold", m.group("bold_t"))) - elif m.group("italic"): - out.append(("italic", m.group("italic_t"))) - elif m.group("code"): - out.append(("code", m.group("code_t"))) - pos = m.end() - if pos < len(text): - out.append(("plain", text[pos:])) - return out or [("plain", text)] - - -# ── 化学式下标白名单(与 proposal/brief 三处渲染器共用同一份)── -# 长的在前,\b 防误伤 LC3 / C595 / 2026;不收 Ca2+ 这类带电荷的(那是上标,白名单不收即天然避开) -_CHEM_RE = re.compile( - r"Ca\(OH\)2|Mg\(OH\)2" - r"|\b(?:Al2O3|Fe2O3|Fe3O4|Mn2O3|Cr2O3|P2O5|Na2SO4|K2SO4|CaSO4|CaCO3|MgCO3|" - r"CaCl2|MgCl2|Na2O|K2O|SiO2|TiO2|ZrO2|SO4|SO3|SO2|CO3|CO2|NO3|NO2|PO4|" - r"H2O|NH3|CH4|C4AF|C3S2|C2AS|C3S|C2S|C3A|O2|N2|H2)\b" -) - - -def _set_subscript(run) -> None: - rPr = run._element.get_or_add_rPr() - va = OxmlElement("w:vertAlign") - va.set(qn("w:val"), "subscript") - rPr.append(va) - +# ───────────────────────── 内联(化学式下标)───────────────────────── def _emit_plain_with_chem(paragraph, text: str, *, size, cn_font: str) -> None: """plain 段:白名单化学式里的数字渲成下标,其余正常。无命中即一条普通 run。""" @@ -179,12 +141,12 @@ def _emit_plain_with_chem(paragraph, text: str, *, size, cn_font: str) -> None: return r = paragraph.add_run(seg) r.font.size = size - _set_run_fonts(r, cn_font=cn_font, en_font="Times New Roman") + set_run_fonts(r, cn_font=cn_font, en_font="Times New Roman") if sub: - _set_subscript(r) + set_subscript(r) pos = 0 - for m in _CHEM_RE.finditer(text): + for m in CHEM_RE.finditer(text): _run(text[pos:m.start()]) buf = "" for ch in m.group(0): @@ -207,12 +169,12 @@ def add_inline(paragraph, text: str, *, size: Pt = Pt(12), cn_font: str = "宋 run.font.size = size if style == "bold": run.bold = True - _set_run_fonts(run, cn_font=cn_font, en_font="Times New Roman") + set_run_fonts(run, cn_font=cn_font, en_font="Times New Roman") elif style == "italic": run.italic = True - _set_run_fonts(run, cn_font=cn_font, en_font="Times New Roman") + set_run_fonts(run, cn_font=cn_font, en_font="Times New Roman") elif style == "code": - _set_run_fonts(run, cn_font=cn_font, en_font="Consolas") + set_run_fonts(run, cn_font=cn_font, en_font="Consolas") # ───────────────────────── 段落 / 标题 / 列表 ───────────────────────── @@ -239,47 +201,9 @@ def add_body_paragraph(doc: Document, text: str, *, indent: bool = True) -> None add_inline(p, text) -# ───────────────────────── 行类型识别 ───────────────────────── +def is_list_item(line: str, prof: dict) -> bool: + return any(p.match(line) for p in prof["list_patterns"]) -_HEADING_RE = re.compile(r"^(#{1,6})\s+(.+)$") -_TABLE_LINE_RE = re.compile(r"^\s*\|.*\|\s*$") -_BLOCKQUOTE_RE = re.compile(r"^\s*>\s?") -_HR_RE = re.compile(r"^\s*-{3,}\s*$|^\s*={3,}\s*$|^\s*_{3,}\s*$") -_FENCE_RE = re.compile(r"^\s*(`{3,}|~{3,})\s*(\S*)\s*$") - -_LIST_PATTERNS = [ - re.compile(r"^\[\d+\]\s"), - re.compile(r"^[-*+]\s"), - re.compile(r"^\d+[\.、.]\s*"), - re.compile(r"^\(\d+\)\s*"), - re.compile(r"^(\d+)\s*"), - re.compile(r"^[一二三四五六七八九十百千]+[、.\.]"), - re.compile(r"^[((][一二三四五六七八九十百千]+[))]"), - re.compile(r"^[①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮]"), -] - - -def is_list_item(line: str) -> bool: - return any(p.match(line) for p in _LIST_PATTERNS) - - -def is_table_line(line: str) -> bool: - return bool(_TABLE_LINE_RE.match(line)) - - -def is_heading(line: str) -> bool: - return bool(_HEADING_RE.match(line)) - - -def is_blockquote(line: str) -> bool: - return bool(_BLOCKQUOTE_RE.match(line)) - - -def is_hr(line: str) -> bool: - return bool(_HR_RE.match(line)) - - -# ───────────────────────── 代码块 / ASCII 图 ───────────────────────── def add_code_block(doc: Document, lines: list[str], lang: str = "") -> None: for ln in lines: @@ -291,26 +215,18 @@ def add_code_block(doc: Document, lines: list[str], lang: str = "") -> None: pf.space_after = Pt(0) run = p.add_run(ln if ln else " ") run.font.size = Pt(10.5) - _set_run_fonts(run, cn_font="新宋体", en_font="Consolas") + set_run_fonts(run, cn_font="新宋体", en_font="Consolas") for t in run._element.iter(qn("w:t")): t.set(qn("xml:space"), "preserve") # ───────────────────────── 表格 ───────────────────────── -def _split_md_row(line: str) -> list[str]: - return [c.strip() for c in line.strip().strip("|").split("|")] - - -def _is_separator_row(cells: list[str]) -> bool: - return all(re.match(r"^[-:\s]+$", c) for c in cells if c != "") - - def render_table(doc: Document, table_lines: list[str]) -> None: rows: list[list[str]] = [] for ln in table_lines: - cells = _split_md_row(ln) - if not cells or _is_separator_row(cells): + cells = common.split_md_row(ln) + if not cells or common.is_separator_row(cells): continue rows.append(cells) if not rows: @@ -341,10 +257,8 @@ def render_table(doc: Document, table_lines: list[str]) -> None: # ───────────────────────── 图片 + 图题 ───────────────────────── -_IMAGE_LINE_RE = re.compile(r"^\s*!\[(?P[^\]]*)\]\((?P[^)\s]+)\)\s*$") _MERMAID_CAPTION_RE = re.compile(r"^\s*%%\s*caption\s*:\s*(.+?)\s*$", re.IGNORECASE) _FILENAME_INVALID_RE = re.compile(r"[^一-鿿A-Za-z0-9]+") -_MAX_IMG_WIDTH = Cm(15) def caption_to_stem(caption: str) -> str: @@ -362,13 +276,6 @@ def extract_mermaid_caption(source: str) -> str | None: return None -def _resolve_image_path(src: str, base_dir: Path) -> Path | None: - p = Path(src) - if not p.is_absolute(): - p = (base_dir / p).resolve() - return p if p.is_file() else None - - def add_image(doc: Document, png_path: Path, caption: str | None, ctx: dict) -> None: p = doc.add_paragraph() p.alignment = WD_ALIGN_PARAGRAPH.CENTER @@ -377,7 +284,7 @@ def add_image(doc: Document, png_path: Path, caption: str | None, ctx: dict) -> p.paragraph_format.space_after = Pt(3) run = p.add_run() try: - run.add_picture(str(png_path), width=_MAX_IMG_WIDTH) + run.add_picture(str(png_path), width=common.MAX_IMG_WIDTH) except Exception as e: run.add_text(f"[image failed: {png_path.name}: {e}]") return @@ -393,12 +300,13 @@ def add_image(doc: Document, png_path: Path, caption: str | None, ctx: dict) -> cap_run = cap_p.add_run(cap_text) cap_run.font.size = Pt(10.5) cap_run.bold = True - _set_run_fonts(cap_run, cn_font="宋体", en_font="Times New Roman") + set_run_fonts(cap_run, cn_font="宋体", en_font="Times New Roman") # ───────────────────────── 主渲染 ───────────────────────── def render_md_block(doc: Document, md_text: str, ctx: dict) -> None: + prof = ctx["prof"] lines = md_text.splitlines() i = 0 n = len(lines) @@ -409,15 +317,15 @@ def render_md_block(doc: Document, md_text: str, ctx: dict) -> None: i += 1 continue - if is_hr(line): + if common.is_hr(line): i += 1 continue - m_img = _IMAGE_LINE_RE.match(line) + m_img = common.IMAGE_LINE_RE.match(line) if m_img: src = m_img.group("src") cap = m_img.group("cap").strip() or None - png = _resolve_image_path(src, ctx["sections_dir"]) + png = common.resolve_image_path(src, ctx["sections_dir"]) if png is not None: add_image(doc, png, cap, ctx) else: @@ -425,14 +333,14 @@ def render_md_block(doc: Document, md_text: str, ctx: dict) -> None: i += 1 continue - m_fence = _FENCE_RE.match(line) + m_fence = common.FENCE_RE.match(line) if m_fence: fence = m_fence.group(1) lang = m_fence.group(2) or "" code: list[str] = [] i += 1 while i < n: - m_close = _FENCE_RE.match(lines[i]) + m_close = common.FENCE_RE.match(lines[i]) if m_close and m_close.group(1)[0] == fence[0] and len(m_close.group(1)) >= len(fence): i += 1 break @@ -452,26 +360,26 @@ def render_md_block(doc: Document, md_text: str, ctx: dict) -> None: add_code_block(doc, code, lang) continue - if is_table_line(line): + if common.is_table_line(line): block: list[str] = [] - while i < n and is_table_line(lines[i]): + while i < n and common.is_table_line(lines[i]): block.append(lines[i]) i += 1 render_table(doc, block) continue - m = _HEADING_RE.match(line) + m = common.HEADING_RE.match(line) if m: level = min(len(m.group(1)), 3) add_heading(doc, m.group(2).strip(), level) i += 1 continue - if is_blockquote(line): + if common.is_blockquote(line): i += 1 continue - if is_list_item(line): + if is_list_item(line, prof): add_body_paragraph(doc, line.strip(), indent=False) i += 1 continue @@ -482,7 +390,8 @@ def render_md_block(doc: Document, md_text: str, ctx: dict) -> None: nxt = lines[j].rstrip() if not nxt.strip(): break - if is_heading(nxt) or is_blockquote(nxt) or is_table_line(nxt) or is_list_item(nxt) or is_hr(nxt): + if (common.is_heading(nxt) or common.is_blockquote(nxt) or common.is_table_line(nxt) + or is_list_item(nxt, prof) or common.is_hr(nxt)): break buf.append(nxt.strip()) j += 1 @@ -492,7 +401,9 @@ def render_md_block(doc: Document, md_text: str, ctx: dict) -> None: # ───────────────────────── 入口 ───────────────────────── -def render_sections(sections_dir: Path, out: Path, lang: str, toc: bool) -> None: +def render_sections(profile: str, sections_dir: Path, out: Path, *, + lang: str = "en", toc: bool = False, fund_type: str = "") -> None: + prof = PROFILES[profile] if not sections_dir.is_dir(): print(f"[ERR] sections dir not found: {sections_dir}", file=sys.stderr) sys.exit(2) @@ -503,19 +414,20 @@ def render_sections(sections_dir: Path, out: Path, lang: str, toc: bool) -> None figures_dir = sections_dir.parent / "figures" ctx: dict = { + "prof": prof, "sections_dir": sections_dir, "figures_dir": figures_dir, "fig_no": 0, - "fig_label": "图" if lang == "zh" else "Fig.", + "fig_label": ("图" if lang == "zh" else "Fig.") if profile == "paper" else "图", } - doc = init_doc() - if toc: - add_toc(doc) + doc = init_doc(prof) + if prof["always_toc"] or toc: + add_toc(doc, prof) for idx, f in enumerate(md_files): text = f.read_text(encoding="utf-8") render_md_block(doc, text, ctx) - if idx != len(md_files) - 1: + if prof["trailing_page_break"] or idx != len(md_files) - 1: doc.add_page_break() out.parent.mkdir(parents=True, exist_ok=True) @@ -525,22 +437,5 @@ def render_sections(sections_dir: Path, out: Path, lang: str, toc: bool) -> None chars = sum(len(p.text) for p in doc.paragraphs) tbls = len(doc.tables) print(f"[OK] rendered {len(md_files)} sections -> {out}") - print(f" paragraphs: {paras} | tables: {tbls} | figures: {ctx['fig_no']} | total chars: {chars}") - print(f" lang: {lang} | toc: {toc}") - print(f" font: 中文宋体小四 / 英文 Times New Roman 小四 / 行距 1.5 / 首行缩进 2 字符") - - -def main() -> None: - ap = argparse.ArgumentParser(description="渲染章节 md → 论文投稿稿 docx") - ap.add_argument("sections_dir", type=Path, help="sections/*.md 目录") - ap.add_argument("--lang", choices=["zh", "en"], default="en", - help="论文语言 (影响图题前缀 图/Fig. 与信息打印); 默认 en") - ap.add_argument("--toc", action="store_true", - help="生成目录页 (期刊投稿稿通常不需要; 内部草稿评阅时可加)") - ap.add_argument("-o", "--output", type=Path, required=True, help="输出 .docx 路径") - args = ap.parse_args() - render_sections(args.sections_dir, args.output, args.lang, args.toc) - - -if __name__ == "__main__": - main() + print(f" profile: {profile} | paragraphs: {paras} | tables: {tbls} | " + f"figures: {ctx['fig_no']} | chars: {chars}") diff --git a/rendering/pdf.py b/rendering/pdf.py new file mode 100644 index 0000000..70726a7 --- /dev/null +++ b/rendering/pdf.py @@ -0,0 +1,177 @@ +"""md(sections 目录或单 .md)→ PDF,沙盒自带 chromium 渲染。 + +渲染链(全程沙盒内,不进 weasyprint、不装额外包): + md --(python `markdown` 库)--> HTML --(chromium --headless --print-to-pdf)--> PDF + +chromium 是镜像里已装的(给 mermaid 用),fonts-noto-cjk 也已装;chromium 是完整浏览器 +内核,CSS 保真度比 weasyprint 高。冒烟见 deploy/sandbox/probe_chromium_pdf.sh。 + +视觉与 docx 一致:复用 common.CHEM_RE(化学式下标白名单,单一事实源)+ 商务红配色 + +DOI/URL 超链。引文 [n] 上标回链这版按字面渲染(后续与 docx 一起 DRY 再补)。 +ASCII-only stdout(Windows GBK 控制台安全)。 +""" +from __future__ import annotations + +import os +import re +import shutil +import subprocess +import tempfile +from pathlib import Path + +from .common import CHEM_RE + +# ───────────────────────── 主题色(与 docx 商务红一致)───────────────────────── +PRIMARY = "#C00000" +TLDR_FILL = "#FBE9E9" +LINK_BLUE = "#1155CC" +TABLE_HEAD_FILL = "#C00000" +TABLE_ZEBRA = "#F8F0F0" + +# 行内 DOI 子串(HTML-safe 边界) +_DOI_INLINE_RE = re.compile(r"10\.\d{4,9}/[^\s<>\"]+") +# 裸 URL / 域名 token +_URL_TOKEN_RE = re.compile( + r"(?\"]*)?)", + re.IGNORECASE, +) +# 切分 HTML 成 [文本, 标签, ...];只对文本 token 做下标/超链替换 +_TAG_SPLIT = re.compile(r"(<[^>]+>)") +_SKIP_TAGS = {"a", "code", "pre", "script", "style", "head"} +_TAG_NAME_RE = re.compile(r"<\s*(/?)\s*([a-zA-Z0-9]+)") + + +def _log(msg: str) -> None: + print(f"[render_pdf] {msg}") + + +def _emit_chem(text: str) -> str: + def repl(m: re.Match) -> str: + return re.sub(r"(\d+)", r"\1", m.group(0)) + return CHEM_RE.sub(repl, text) + + +def _emit_links(text: str) -> str: + def doi_repl(m: re.Match) -> str: + doi = m.group(0) + return f'{doi}' + text = _DOI_INLINE_RE.sub(doi_repl, text) + + out_parts = [] + for piece in _TAG_SPLIT.split(text): + if piece.startswith("<"): + out_parts.append(piece) + continue + + def url_repl(m: re.Match) -> str: + raw = m.group(1) + href = raw if raw.lower().startswith("http") else f"https://{raw}" + return f'{raw}' + + out_parts.append(_URL_TOKEN_RE.sub(url_repl, piece)) + return "".join(out_parts) + + +def _enrich_html(html: str) -> str: + """对 HTML 纯文本片段做化学式下标 + DOI/URL 超链;//
 内不动。"""
+    out = []
+    skip_depth = 0
+    for token in _TAG_SPLIT.split(html):
+        if not token:
+            continue
+        if token.startswith("<"):
+            m = _TAG_NAME_RE.match(token)
+            if m:
+                closing, name = m.group(1), m.group(2).lower()
+                if name in _SKIP_TAGS and not token.rstrip().endswith("/>"):
+                    skip_depth += -1 if closing else 1
+                    skip_depth = max(0, skip_depth)
+            out.append(token)
+        else:
+            out.append(token if skip_depth else _emit_links(_emit_chem(token)))
+    return "".join(out)
+
+
+def _read_sections(src: Path) -> str:
+    if src.is_dir():
+        parts = [md.read_text(encoding="utf-8") for md in sorted(src.glob("*.md"))]
+        if not parts:
+            raise SystemExit(f"[render_pdf] no *.md under {src}")
+        return "\n\n".join(parts)
+    return src.read_text(encoding="utf-8")
+
+
+def _css(color: bool) -> str:
+    primary = PRIMARY if color else "#000000"
+    head_fill = TABLE_HEAD_FILL if color else "#000000"
+    zebra = TABLE_ZEBRA if color else "#FFFFFF"
+    tldr = TLDR_FILL if color else "#FFFFFF"
+    link = LINK_BLUE if color else "#000000"
+    return f"""
+@page {{ size: A4; margin: 2.2cm 2cm; }}
+* {{ -webkit-print-color-adjust: exact; print-color-adjust: exact; }}
+body {{ font-family: 'Times New Roman','Noto Serif CJK SC','Noto Sans CJK SC',serif;
+        font-size: 12pt; line-height: 1.6; color: #000; }}
+h1 {{ font-family: 'Noto Sans CJK SC',sans-serif; font-size: 19pt; color: {primary};
+      border-bottom: 2px solid {primary}; padding-bottom: 4pt; margin: 22pt 0 12pt; }}
+h2 {{ font-family: 'Noto Sans CJK SC',sans-serif; font-size: 15pt; color: {primary}; margin: 20pt 0 8pt; }}
+h3 {{ font-family: 'Noto Sans CJK SC',sans-serif; font-size: 13pt; color: {primary}; margin: 16pt 0 6pt; }}
+p {{ text-align: justify; margin: 6pt 0; }}
+a {{ color: {link}; text-decoration: underline; word-break: break-all; }}
+sub {{ font-size: 0.72em; }}
+table {{ border-collapse: collapse; width: 100%; margin: 12pt 0; font-size: 10.5pt; }}
+th {{ background: {head_fill}; color: #fff; padding: 6pt 8pt; border: 1px solid #999; text-align: center; }}
+td {{ padding: 5pt 8pt; border: 1px solid #999; }}
+tr:nth-child(even) td {{ background: {zebra}; }}
+blockquote {{ border-left: 4px solid {primary}; background: {tldr}; margin: 12pt 0;
+              padding: 8pt 12pt; font-size: 11pt; }}
+blockquote p {{ margin: 3pt 0; }}
+code {{ font-family: Consolas,monospace; font-size: 10pt; background: #f5f5f5; padding: 1pt 3pt; }}
+ul,ol {{ margin: 6pt 0; padding-left: 22pt; }}
+li {{ margin: 3pt 0; }}
+"""
+
+
+def _find_chromium() -> str:
+    env = os.environ.get("CHROMIUM") or os.environ.get("CHROME")
+    cands = [env] if env else []
+    cands += ["chromium", "chromium-browser", "google-chrome",
+              "/usr/bin/chromium", "/usr/bin/chromium-browser"]
+    for c in cands:
+        if c and (shutil.which(c) or Path(c).exists()):
+            return shutil.which(c) or c
+    raise SystemExit("[render_pdf] chromium 不在沙盒里(镜像应已装,给 mermaid 用)。"
+                     "确认 `which chromium` 或设 CHROMIUM 环境变量。")
+
+
+def md_to_pdf(src: Path, out: Path, *, color: bool = True, profile: str = "") -> Path:
+    try:
+        import markdown
+    except ImportError:
+        raise SystemExit("[render_pdf] 缺 `markdown` 包。基础镜像应已装(requirements.txt);"
+                         "本地补:.venv/Scripts/python.exe -m pip install markdown")
+
+    md_text = _read_sections(src)
+    body = markdown.markdown(
+        md_text, extensions=["tables", "fenced_code", "sane_lists", "attr_list"]
+    )
+    body = _enrich_html(body)
+    html = (f''
+            f"{body}")
+
+    chromium = _find_chromium()
+    out.parent.mkdir(parents=True, exist_ok=True)
+    with tempfile.TemporaryDirectory(prefix="render-pdf-") as tmp:
+        html_path = Path(tmp) / "doc.html"
+        html_path.write_text(html, encoding="utf-8")
+        cmd = [
+            chromium, "--headless", "--disable-gpu", "--no-sandbox",
+            "--disable-dev-shm-usage", f"--user-data-dir={tmp}/cr",
+            "--no-pdf-header-footer",
+            f"--print-to-pdf={out}", html_path.as_uri(),
+        ]
+        proc = subprocess.run(cmd, capture_output=True, timeout=120, check=False)
+        if proc.returncode != 0 or not out.exists() or out.stat().st_size == 0:
+            tail = (proc.stderr or proc.stdout or b"").decode("utf-8", "replace")[-600:]
+            raise SystemExit(f"[render_pdf] chromium 转 PDF 失败(rc={proc.returncode}):\n{tail}")
+    return out
diff --git a/rendering/render.py b/rendering/render.py
new file mode 100644
index 0000000..b0d417b
--- /dev/null
+++ b/rendering/render.py
@@ -0,0 +1,63 @@
+"""平台渲染统一入口。各 skill 出 docx/pdf 都调这一个,不再自带 render 脚本。
+
+用法(沙盒内 / host 同):
+    python /sandbox/rendering/render.py --profile brief    --format docx  -o out.docx
+    python /sandbox/rendering/render.py --profile brief    --format pdf   -o out.pdf
+    python /sandbox/rendering/render.py --profile paper     --format docx  --lang zh -o out.docx
+    python /sandbox/rendering/render.py --profile proposal  --format docx  --fund-type key_rd -o out.docx
+
+--no-color 出黑白(brief docx / 任意 pdf 生效)。 可为目录(拼接其 *.md)或单个 .md。
+"""
+from __future__ import annotations
+
+import argparse
+import os
+import sys
+from pathlib import Path
+
+# bootstrap:让 `import rendering.*` 在 `python /sandbox/rendering/render.py` 直接调时也能解析。
+# render.py 恒在 /rendering/render.py,故 dirname(dirname(__file__)) 恒为含 rendering/ 的根
+# (沙盒=/sandbox,host=repo 根),与挂载点 / 深度无关。
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from rendering import docx_brief, docx_manuscript, pdf  # noqa: E402
+
+
+def main(argv: list[str] | None = None) -> int:
+    ap = argparse.ArgumentParser(description="md(sections 目录或单 .md)→ docx / pdf")
+    ap.add_argument("src", type=Path, help="sections 目录(拼接其 *.md)或单个 .md")
+    ap.add_argument("--profile", required=True, choices=["brief", "paper", "proposal"])
+    ap.add_argument("--format", default="docx", choices=["docx", "pdf"])
+    ap.add_argument("-o", "--output", type=Path, required=True, help="输出路径")
+    ap.add_argument("--no-color", dest="color", action="store_false",
+                    help="关配色出黑白(brief docx / pdf 生效)")
+    ap.add_argument("--lang", choices=["zh", "en"], default="en",
+                    help="paper 图题前缀 图/Fig.;默认 en")
+    ap.add_argument("--toc", action="store_true", help="paper 生成目录页(proposal 始终带)")
+    ap.add_argument("--fund-type", default="key_rd",
+                    help="proposal 基金类型(仅打印标注)")
+    args = ap.parse_args(argv)
+
+    if not args.src.exists():
+        print(f"[render] 输入不存在:{args.src}", file=sys.stderr)
+        return 1
+
+    if args.format == "pdf":
+        out = pdf.md_to_pdf(args.src, args.output, color=args.color, profile=args.profile)
+        print(f"[render] OK pdf -> {out} ({out.stat().st_size} bytes)")
+        return 0
+
+    # docx
+    if args.profile == "brief":
+        docx_brief.render_sections(args.src, args.output, args.color)
+    elif args.profile == "paper":
+        docx_manuscript.render_sections("paper", args.src, args.output,
+                                        lang=args.lang, toc=args.toc)
+    else:  # proposal
+        docx_manuscript.render_sections("proposal", args.src, args.output,
+                                        fund_type=args.fund_type)
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/requirements.txt b/requirements.txt
index 1b2eae1..f4215d0 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -7,6 +7,7 @@ rich>=13.7.0
 python-pptx>=0.6.21
 python-docx>=1.1.0
 matplotlib>=3.8.0
+markdown>=3.5    # skills/_shared/render_pdf.py: md→HTML→chromium 出 PDF(纯 Python,host/sandbox 通吃)
 
 # 素材摄取: PDF/DOCX/PPTX/XLSX/HTML/URL → Markdown (ppt 阶段零 + proposal 阶段零)
 markitdown[pdf,docx,pptx,xlsx]>=0.0.1
diff --git a/skills/brief/SKILL.md b/skills/brief/SKILL.md
index dfd052d..f9e3689 100644
--- a/skills/brief/SKILL.md
+++ b/skills/brief/SKILL.md
@@ -23,9 +23,9 @@ description: 生成科研方向简报(research direction briefing / 重要文献
 ## 资源(路径相对 `load_skill` 头里的 `dir=<绝对路径>`)
 
 - `references/journals.md` —— 各建材子领域主流期刊清单(Elsevier 数据库优先)+ 精确 `publication_name` + 0 命中降级法。**阶段二必读**。
-- `scripts/render_docx.py` —— md→docx,商务红主题 + 列表 `[n]` 锚点 + 正文 `[n]`/`[Wn]` 引文上标回链 + DOI/URL 可点超链接 + 化学式下标白名单(CO2/C3S/Na2O...,不误伤 LC3/C595/Ca2+)。用 `.venv/Scripts/python.exe` 跑。
+- **平台渲染层 `/sandbox/rendering/render.py`**(各 skill 通用,不再自带 render 脚本)—— `--profile brief --format docx|pdf`。docx:商务红主题 + 列表 `[n]` 锚点 + 正文 `[n]`/`[Wn]` 引文上标回链 + DOI/URL 超链 + 化学式下标白名单(CO2/C3S/Na2O...,不误伤 LC3/C595/Ca2+);pdf:沙盒自带 chromium 渲染(`md→HTML→chromium`),同套主题 + DOI/URL 超链 + 化学式下标。**渲染一律调它,禁止自己手搓 HTML / pip 装 weasyprint。**
 
-产物默认 `.md`;要 docx 用 render_docx.py;要 deck 转 `ppt` skill。
+产物默认 `.md`;要 docx/pdf 调 `render.py --profile brief`;要 deck 转 `ppt` skill。
 
 ## 阶段一:定题对齐(BLOCKING)
 
@@ -62,7 +62,7 @@ for jname in ["Cement and Concrete Research", "Cement and Concrete Composites",
 - 汇成证据表 `/evidence.md`:期刊 | 标题 | 第一作者(机构)| 年-月 | 摘要概述 | DOI | 来源(research/documents/web)。
 - 跨源去重:同 DOI 一条(documents 全文优先,DOI 记自 research);web 不与论文去重、单列。
 
-> **库时效(必交代)**:research(OpenAlex)约 3 个月索引滞后,"最新"= 库内最新。窗口内 0 篇 → 如实告知库未收录该窗口,可用 web 补更近的非论文动向,**不脑补文献**。
+> **窗口内 0 篇**:如实告知库内该窗口暂无收录(可能该刊本窗口尚未发文),可用 web 补更近的非论文动向,**不脑补文献**。
 
 ## 阶段三:列清单 + 内容总结(写 `/sections/*.md`)
 
@@ -95,7 +95,8 @@ for jname in ["Cement and Concrete Research", "Cement and Concrete Composites",
 
 ## 阶段五:渲染验收
 
-- 用户要 docx → `.venv/Scripts/python.exe /scripts/render_docx.py  -o <方向>-简报.docx`(`--no-color` 出黑白);要 deck → 转 ppt。
+- 用户要 docx → `python /sandbox/rendering/render.py --profile brief --format docx  -o <方向>-简报.docx`(`--no-color` 出黑白);要 deck → 转 ppt。
+- 用户要 pdf → `python /sandbox/rendering/render.py --profile brief --format pdf  -o <方向>-简报.pdf`(沙盒内 chromium 渲染,同样 `--no-color` 出黑白)。**别现搓 weasyprint / 现 pip 装包** —— 直接调 render.py。
 - 渲染前自查:`[CITE-]`/`` 占位是否清干净、正文 `[n]` 与列表 `[n]` 是否对得上(无 orphan)、有没有混进"建议/启示/本院应当"措辞。
 - 交付一句话说清:覆盖了哪些期刊、收了多少篇、时间窗、哪些刊本窗口库内无收录。
 
diff --git a/skills/paper/SKILL.md b/skills/paper/SKILL.md
index 9845802..6e21263 100644
--- a/skills/paper/SKILL.md
+++ b/skills/paper/SKILL.md
@@ -41,7 +41,7 @@ description: 撰写学术期刊投稿论文(中文核心 / 英文 SCI;原创研
 
 **脚本**(`.venv/Scripts/python.exe /scripts/...`):
 - `scripts/render_diagrams.py` —— sections/*.md 的 ```mermaid``` 块 → `figures/fig_.png`(caption 必填+唯一)
-- `scripts/render_docx.py` —— md→docx,`--lang {zh,en}`(图题 图/Fig.),`--toc`(默认不出目录),自动 `**bold**`/列表/表格/`![](png)` 居中插图 + 图题自增
+- **平台渲染层 `/sandbox/rendering/render.py --profile paper`**(不再自带 render_docx)—— md→docx,`--lang {zh,en}`(图题 图/Fig.),`--toc`(默认不出目录),自动 `**bold**`/列表/表格/`![](png)` 居中插图 + 图题自增;要 pdf 加 `--format pdf`。**渲染一律调它,别自己手搓。**
 - `scripts/word_count.py` —— `--type --lang`,章节篇幅 vs 预算
 - `scripts/quality_check.py` —— `--type`,结构/占位符/过度宣称/插图 + **引文交叉核对**(orphan/uncited/编号连续)
 
@@ -149,7 +149,7 @@ spec 定下「类型 + 语言」后,**按 §资源 条件加载**对应的 cite_
 python /scripts/word_count.py      /sections/ --type original --lang en
 python /scripts/quality_check.py   /sections/ --type original
 python /scripts/render_diagrams.py /sections/          # 有 ```mermaid 块就跑
-python /scripts/render_docx.py     /sections/ --lang en -o /.docx
+python /sandbox/rendering/render.py --profile paper --format docx /sections/ --lang en -o /.docx
 ```
 
 - `quality_check` 的 orphan/uncited/占位符不通过 → 回头改章节或补阶段五核验,再跑
diff --git a/skills/patent/SKILL.md b/skills/patent/SKILL.md
index adb2d09..f713b22 100644
--- a/skills/patent/SKILL.md
+++ b/skills/patent/SKILL.md
@@ -17,7 +17,7 @@ description: 撰写中国发明专利技术交底书 (供专利代理师转写
 - `/references/self_check.md` —— 渲染前自查清单(参数/公式一致、逻辑闭环、脱敏、附图)
 - `/templates/spec.md` —— task 级"宪法"模板(案件名 / 技术领域 / 创新点清单 / 检索结论 / 脱敏边界 / 附图清单)
 - `/templates/disclosure.md` —— 交底书 7 章 Markdown 模板,阶段四照抄
-- **渲染脚本复用 proposal skill**:`skills/proposal/scripts/render_diagrams.py` + `render_docx.py` —— 跟交底书 md 兼容(同样的 markdown + ```mermaid``` + `%% caption:` 约定),不另写
+- **渲染复用平台层 + proposal 图脚本**:docx 调 `rendering/render.py --profile proposal`(见下);mermaid 图仍用 `skills/proposal/scripts/render_diagrams.py` 预渲染 `figures/fig_.png` —— 同样的 markdown + ```mermaid``` + `%% caption:` 约定,不另写
 
 ## 阶段零: 摄取素材 (有 PDF/DOCX/PPTX/XLSX/URL 时才走)
 
@@ -130,8 +130,8 @@ read /references/self_check.md
 # 2. mermaid 附图预渲染 (章节有 ```mermaid``` 块就跑)
 python /../proposal/scripts/render_diagrams.py /sections/
 
-# 3. 渲染 .docx (复用 proposal skill 的脚本,patent 不另写)
-python /../proposal/scripts/render_docx.py /sections/ --fund-type key_rd -o /<案件名>_技术交底书.docx
+# 3. 渲染 .docx (调平台渲染层,复用 proposal profile)
+python /sandbox/rendering/render.py --profile proposal --format docx /sections/ --fund-type key_rd -o /<案件名>_技术交底书.docx
 ```
 
 > `render_docx.py` 的 `--fund-type` 只影响目录页表头文案与封面,不影响章节解析 —— 交底书复用 `key_rd` 排版规范(国标黑体/宋体/1.5 倍行距)。封面页用户拿到后手动改成"技术交底书"标题,或在 sections/00_封面.md 自定义。
diff --git a/skills/proposal/SKILL.md b/skills/proposal/SKILL.md
index 41f9c42..99c24de 100644
--- a/skills/proposal/SKILL.md
+++ b/skills/proposal/SKILL.md
@@ -19,7 +19,7 @@ description: 撰写中国科研项目申报书 / 课题任务书 (国家重点
 - `/references/budget_rules.md` —— 间接费用台阶 + B1-B4 表
 - `/templates/spec.md` —— 阶段一八条对齐的固定字段模板 (复制到 task 级 spec 文件,文件名见下文 §阶段一)
 - `/templates/{key_rd,major_project,nsfc_joint_fund}.md` —— **有完整章节模板**的 3 类基金;其它 4 类 (`nsfc_general` / `nsfc_youth` / `provincial` / `enterprise`) 复用 `nsfc_joint_fund` 或 `key_rd` 骨架,差异看 `fund_types.md` § 4-6
-- `/scripts/render_docx.py` —— md→docx,自动加目录 / 解析 `**bold**`/`*italic*`/`` `code` `` / 列表分行 / `![](path)` 居中插图 + 图题自动编号 / 识别 mermaid 块按 caption 查 `figures/fig_.png`
+- **平台渲染层 `/sandbox/rendering/render.py --profile proposal`**(不再自带 render_docx)—— md→docx,自动加目录 / 解析 `**bold**`/`*italic*`/`` `code` `` / 列表分行 / `![](path)` 居中插图 + 图题自动编号 / 识别 mermaid 块按 caption 查 `figures/fig_.png`;要 pdf 加 `--format pdf`。**渲染一律调它,别自己手搓。**
 - `/scripts/render_diagrams.py` —— sections/*.md 里的 ```mermaid``` 块预渲染成 `/figures/fig_.png`(caption 必填 + 全 task 唯一,优先 `mmdc`、回退 `mermaid.ink`)
 - `/scripts/word_count.py` —— 章节字数 vs 预算
 - `/scripts/quality_check.py` —— 结构完整性 / 假大空 / 占位符 / 指南覆盖度(`--spec`)/ 插图(无 `![]()` 引用 / ASCII 字符画 / mermaid 缺 caption / caption 撞名)
@@ -94,7 +94,7 @@ glob /*--*.spec.md → 按文件名字典序排,取最
 python /scripts/word_count.py      /sections/ --fund-type key_rd
 python /scripts/quality_check.py   /sections/ --fund-type key_rd --spec /--.spec.md
 python /scripts/render_diagrams.py /sections/        # 章节有 ```mermaid 块就跑
-python /scripts/render_docx.py     /sections/ --fund-type key_rd -o /.docx
+python /sandbox/rendering/render.py --profile proposal --format docx /sections/ --fund-type key_rd -o /.docx
 ```
 
 `quality_check` 不通过的项回头 edit 章节再跑。
diff --git a/skills/proposal/scripts/render_docx.py b/skills/proposal/scripts/render_docx.py
deleted file mode 100644
index 93239c7..0000000
--- a/skills/proposal/scripts/render_docx.py
+++ /dev/null
@@ -1,604 +0,0 @@
-"""把 sections/*.md 渲染成符合中国基金申报书排版规范的 .docx。
-
-字体规范:
-- 标题黑体 (一二级) / 三级标题宋体 / 正文中文宋体 / 英文 Times New Roman
-- 行距 1.5 倍 / 首行缩进 2 字符
-- A4 纸 / 上下 2.5cm / 左 3.0cm / 右 2.0cm
-
-特性:
-- 自动插入"目录"页 (Word 内右键更新域 / F9 即生成 TOC)
-- 内联 markdown 解析: **加粗** / *斜体* / `等宽`
-- 列表/引用文献项 ([N], 1., (1), 一、, -, *) 各自独立成段
-- markdown 表格自动识别, 包含分隔行 |---|---|
-- 图片 ![caption](path.png) 居中插入 + 图题自动编号 (图 1 / 图 2 / ...)
-- ```mermaid``` 块: 读首行 `%% caption: <题>`, 按 caption 清洗后查
-  /../figures/fig_.png, 命中走图 + 图题;
-  未命中走 ASCII fallback (等宽字体保留 box drawing)。
-  PNG 由 render_diagrams.py 预生成, 本脚本只做查表 + 插入。
-
-用法:
-  python render_docx.py  --fund-type key_rd -o 
-"""
-from __future__ import annotations
-import argparse
-import re
-import sys
-from pathlib import Path
-
-from docx import Document
-from docx.enum.text import WD_ALIGN_PARAGRAPH
-from docx.oxml import OxmlElement
-from docx.oxml.ns import qn
-from docx.shared import Cm, Pt, RGBColor
-
-
-# ───────────────────────── 字体辅助 ─────────────────────────
-
-def _set_run_fonts(run, *, cn_font: str = "宋体", en_font: str = "Times New Roman") -> None:
-    """同时设置 run 的中文 (eastAsia) 和西文 (ascii/hAnsi) 字体。"""
-    rPr = run._element.get_or_add_rPr()
-    rFonts = rPr.find(qn("w:rFonts"))
-    if rFonts is None:
-        rFonts = OxmlElement("w:rFonts")
-        rPr.append(rFonts)
-    rFonts.set(qn("w:eastAsia"), cn_font)
-    rFonts.set(qn("w:ascii"), en_font)
-    rFonts.set(qn("w:hAnsi"), en_font)
-
-
-def _set_style_fonts(style, *, cn_font: str = "宋体", en_font: str = "Times New Roman") -> None:
-    """直接给 style 写 rFonts, 这样基于该 style 的所有段落都继承字体。"""
-    el = style.element
-    rPr = el.find(qn("w:rPr"))
-    if rPr is None:
-        rPr = OxmlElement("w:rPr")
-        el.insert(0, rPr)
-    rFonts = rPr.find(qn("w:rFonts"))
-    if rFonts is None:
-        rFonts = OxmlElement("w:rFonts")
-        rPr.append(rFonts)
-    rFonts.set(qn("w:eastAsia"), cn_font)
-    rFonts.set(qn("w:ascii"), en_font)
-    rFonts.set(qn("w:hAnsi"), en_font)
-
-
-# ───────────────────────── 文档初始化 ─────────────────────────
-
-def init_doc() -> Document:
-    doc = Document()
-
-    # 页面
-    section = doc.sections[0]
-    section.page_height = Cm(29.7)
-    section.page_width = Cm(21)
-    section.top_margin = Cm(2.5)
-    section.bottom_margin = Cm(2.5)
-    section.left_margin = Cm(3.0)
-    section.right_margin = Cm(2.0)
-
-    # Normal 样式 (正文)
-    normal = doc.styles["Normal"]
-    normal.font.name = "Times New Roman"
-    normal.font.size = Pt(12)  # 小四
-    _set_style_fonts(normal, cn_font="宋体")
-    pf = normal.paragraph_format
-    pf.line_spacing = 1.5
-    pf.space_before = Pt(0)
-    pf.space_after = Pt(0)
-
-    # Heading 样式 — 让 Word TOC 域识别
-    for lvl, sz, cn in [(1, Pt(14), "黑体"), (2, Pt(12), "黑体"), (3, Pt(12), "宋体")]:
-        h = doc.styles[f"Heading {lvl}"]
-        h.font.name = "Times New Roman"
-        h.font.size = sz
-        h.font.bold = True
-        h.font.color.rgb = RGBColor(0, 0, 0)  # 覆盖 builtin 蓝色
-        _set_style_fonts(h, cn_font=cn)
-        h.paragraph_format.line_spacing = 1.5
-        h.paragraph_format.space_before = Pt(6)
-        h.paragraph_format.space_after = Pt(3)
-        h.paragraph_format.first_line_indent = None
-
-    return doc
-
-
-# ───────────────────────── TOC ─────────────────────────
-
-def add_toc(doc: Document, depth: int = 3) -> None:
-    """在文档开头插入 '目录' 标题 + Word 域 TOC。
-
-    Word 打开时不会自动展开;用户右键域 → '更新域' 或按 F9。
-    LibreOffice 打开会更直接显示。
-    """
-    # "目  录" 标题 (居中, 不用 Heading 样式以免自我包含)
-    p = doc.add_paragraph()
-    p.alignment = WD_ALIGN_PARAGRAPH.CENTER
-    p.paragraph_format.first_line_indent = None
-    p.paragraph_format.space_before = Pt(12)
-    p.paragraph_format.space_after = Pt(6)
-    run = p.add_run("目  录")
-    run.font.size = Pt(16)  # 三号
-    run.font.bold = True
-    _set_run_fonts(run, cn_font="黑体")
-
-    # TOC 域
-    p = doc.add_paragraph()
-    p.paragraph_format.first_line_indent = None
-    run = p.add_run()
-
-    fldChar1 = OxmlElement("w:fldChar")
-    fldChar1.set(qn("w:fldCharType"), "begin")
-
-    instrText = OxmlElement("w:instrText")
-    instrText.set(qn("xml:space"), "preserve")
-    instrText.text = f' TOC \\o "1-{depth}" \\h \\z \\u '
-
-    fldChar2 = OxmlElement("w:fldChar")
-    fldChar2.set(qn("w:fldCharType"), "separate")
-
-    fldChar3 = OxmlElement("w:fldChar")
-    fldChar3.set(qn("w:fldCharType"), "end")
-
-    # 占位文字 — Word 更新域时会被实际目录替换
-    placeholder_t = OxmlElement("w:t")
-    placeholder_t.set(qn("xml:space"), "preserve")
-    placeholder_t.text = "[在 Word 中按 F9 或右键此处选择 “更新域” 即可生成完整目录]"
-
-    run._element.append(fldChar1)
-    run._element.append(instrText)
-    run._element.append(fldChar2)
-    run._element.append(placeholder_t)
-    run._element.append(fldChar3)
-
-    doc.add_page_break()
-
-
-# ───────────────────────── 内联 markdown ─────────────────────────
-
-# 顺序敏感:**bold** 必须先于 *italic* 匹配, 否则会被 italic 抢
-_INLINE_RE = re.compile(
-    r"(?P\*\*(?P[^*\n]+?)\*\*)"
-    r"|(?P(?[^*\n]+?)\*(?!\*))"
-    r"|(?P`(?P[^`\n]+?)`)"
-)
-
-
-def parse_inline(text: str) -> list[tuple[str, str]]:
-    """切成 (style, segment) 列表; style ∈ plain/bold/italic/code。"""
-    out: list[tuple[str, str]] = []
-    pos = 0
-    for m in _INLINE_RE.finditer(text):
-        if m.start() > pos:
-            out.append(("plain", text[pos:m.start()]))
-        if m.group("bold"):
-            out.append(("bold", m.group("bold_t")))
-        elif m.group("italic"):
-            out.append(("italic", m.group("italic_t")))
-        elif m.group("code"):
-            out.append(("code", m.group("code_t")))
-        pos = m.end()
-    if pos < len(text):
-        out.append(("plain", text[pos:]))
-    return out or [("plain", text)]
-
-
-# ── 化学式下标白名单(与 paper/brief 三处渲染器共用同一份)──
-# 长的在前,\b 防误伤 LC3 / C595 / 2026;不收 Ca2+ 这类带电荷的(那是上标,白名单不收即天然避开)
-_CHEM_RE = re.compile(
-    r"Ca\(OH\)2|Mg\(OH\)2"
-    r"|\b(?:Al2O3|Fe2O3|Fe3O4|Mn2O3|Cr2O3|P2O5|Na2SO4|K2SO4|CaSO4|CaCO3|MgCO3|"
-    r"CaCl2|MgCl2|Na2O|K2O|SiO2|TiO2|ZrO2|SO4|SO3|SO2|CO3|CO2|NO3|NO2|PO4|"
-    r"H2O|NH3|CH4|C4AF|C3S2|C2AS|C3S|C2S|C3A|O2|N2|H2)\b"
-)
-
-
-def _set_subscript(run) -> None:
-    rPr = run._element.get_or_add_rPr()
-    va = OxmlElement("w:vertAlign")
-    va.set(qn("w:val"), "subscript")
-    rPr.append(va)
-
-
-def _emit_plain_with_chem(paragraph, text: str, *, size, cn_font: str) -> None:
-    """plain 段:白名单化学式里的数字渲成下标,其余正常。无命中即一条普通 run。"""
-    def _run(seg: str, sub: bool = False):
-        if not seg:
-            return
-        r = paragraph.add_run(seg)
-        r.font.size = size
-        _set_run_fonts(r, cn_font=cn_font, en_font="Times New Roman")
-        if sub:
-            _set_subscript(r)
-
-    pos = 0
-    for m in _CHEM_RE.finditer(text):
-        _run(text[pos:m.start()])
-        buf = ""
-        for ch in m.group(0):
-            if ch.isdigit():
-                _run(buf); buf = ""
-                _run(ch, sub=True)
-            else:
-                buf += ch
-        _run(buf)
-        pos = m.end()
-    _run(text[pos:])
-
-
-def add_inline(paragraph, text: str, *, size: Pt = Pt(12), cn_font: str = "宋体") -> None:
-    for style, seg in parse_inline(text):
-        if style == "plain":
-            _emit_plain_with_chem(paragraph, seg, size=size, cn_font=cn_font)
-            continue
-        run = paragraph.add_run(seg)
-        run.font.size = size
-        if style == "bold":
-            run.bold = True
-            _set_run_fonts(run, cn_font=cn_font, en_font="Times New Roman")
-        elif style == "italic":
-            run.italic = True
-            _set_run_fonts(run, cn_font=cn_font, en_font="Times New Roman")
-        elif style == "code":
-            _set_run_fonts(run, cn_font=cn_font, en_font="Consolas")
-
-
-# ───────────────────────── 段落 / 标题 / 列表 ─────────────────────────
-
-def add_heading(doc: Document, text: str, level: int) -> None:
-    p = doc.add_paragraph(style=f"Heading {level}")
-    p.paragraph_format.first_line_indent = None
-    # 标题里通常无内联 markdown, 但万一有也按内联解析 (黑体大小由 style 已设)
-    sizes = {1: Pt(14), 2: Pt(12), 3: Pt(12)}
-    cn = {1: "黑体", 2: "黑体", 3: "宋体"}
-    add_inline(p, text, size=sizes[level], cn_font=cn[level])
-    for run in p.runs:
-        run.bold = True
-
-
-def add_body_paragraph(doc: Document, text: str, *, indent: bool = True) -> None:
-    p = doc.add_paragraph()
-    pf = p.paragraph_format
-    pf.line_spacing = 1.5
-    if indent:
-        pf.first_line_indent = Pt(24)  # 2 字符
-    else:
-        pf.first_line_indent = None
-    p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
-    add_inline(p, text)
-
-
-# ───────────────────────── 行类型识别 ─────────────────────────
-
-_HEADING_RE = re.compile(r"^(#{1,6})\s+(.+)$")
-_TABLE_LINE_RE = re.compile(r"^\s*\|.*\|\s*$")
-_BLOCKQUOTE_RE = re.compile(r"^\s*>\s?")
-_HR_RE = re.compile(r"^\s*-{3,}\s*$|^\s*={3,}\s*$|^\s*_{3,}\s*$")
-_FENCE_RE = re.compile(r"^\s*(`{3,}|~{3,})\s*(\S*)\s*$")
-
-# 列表项 (各自独立成段, 不跟相邻行合并, 不缩进首行)
-_LIST_PATTERNS = [
-    re.compile(r"^\[\d+\]\s"),                                  # [1]
-    re.compile(r"^[-*+]\s"),                                    # - / * / +
-    re.compile(r"^\d+[\.、.]\s*"),                             # 1.  / 1、 / 1.
-    re.compile(r"^\(\d+\)\s*"),                                 # (1)
-    re.compile(r"^(\d+)\s*"),                                 # (1)
-    re.compile(r"^[一二三四五六七八九十百千]+[、.\.]"),       # 一、
-    re.compile(r"^[((][一二三四五六七八九十百千]+[))]"),      # (一)
-    re.compile(r"^[①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮]"),                  # ①
-    re.compile(r"^第[一二三四五六七八九十百]+[条章节]"),        # 第一条
-]
-
-
-def is_list_item(line: str) -> bool:
-    return any(p.match(line) for p in _LIST_PATTERNS)
-
-
-def is_table_line(line: str) -> bool:
-    return bool(_TABLE_LINE_RE.match(line))
-
-
-def is_heading(line: str) -> bool:
-    return bool(_HEADING_RE.match(line))
-
-
-def is_blockquote(line: str) -> bool:
-    return bool(_BLOCKQUOTE_RE.match(line))
-
-
-def is_hr(line: str) -> bool:
-    return bool(_HR_RE.match(line))
-
-
-# ───────────────────────── 代码块 / ASCII 图 ─────────────────────────
-
-def add_code_block(doc: Document, lines: list[str], lang: str = "") -> None:
-    """fenced ``` 块 / ASCII 流程图: 等宽字体 + 行距 1.0 + 不缩进 + 不解析内联 + 保留空格。
-
-    中文用"新宋体"(NSimSun, Windows 自带等宽宋体), 西文用 Consolas, 这样
-    `─ │ ┌ ┐` 这类 box drawing 字符与中文字符的视觉宽度更接近, ASCII 流程图不至于错位。
-    """
-    for ln in lines:
-        p = doc.add_paragraph()
-        pf = p.paragraph_format
-        pf.first_line_indent = None
-        pf.line_spacing = 1.0
-        pf.space_before = Pt(0)
-        pf.space_after = Pt(0)
-        run = p.add_run(ln if ln else " ")  # 空行也占一行高
-        run.font.size = Pt(10.5)  # 五号
-        _set_run_fonts(run, cn_font="新宋体", en_font="Consolas")
-        # docx 默认会压缩连续空格 -> 显式 xml:space=preserve, 否则 ASCII 对齐会被破坏
-        for t in run._element.iter(qn("w:t")):
-            t.set(qn("xml:space"), "preserve")
-
-
-# ───────────────────────── 表格 ─────────────────────────
-
-def _split_md_row(line: str) -> list[str]:
-    return [c.strip() for c in line.strip().strip("|").split("|")]
-
-
-def _is_separator_row(cells: list[str]) -> bool:
-    return all(re.match(r"^[-:\s]+$", c) for c in cells if c != "")
-
-
-def render_table(doc: Document, table_lines: list[str]) -> None:
-    rows: list[list[str]] = []
-    for ln in table_lines:
-        cells = _split_md_row(ln)
-        if not cells or _is_separator_row(cells):
-            continue
-        rows.append(cells)
-    if not rows:
-        return
-    n_cols = max(len(r) for r in rows)
-    for r in rows:
-        while len(r) < n_cols:
-            r.append("")
-
-    table = doc.add_table(rows=len(rows), cols=n_cols)
-    try:
-        table.style = "Light Grid Accent 1"
-    except KeyError:
-        pass  # style 不存在就用默认
-
-    for ri, row in enumerate(rows):
-        for ci, val in enumerate(row):
-            cell = table.rows[ri].cells[ci]
-            # 清掉 cell 默认空段落
-            cell.text = ""
-            p = cell.paragraphs[0]
-            p.paragraph_format.first_line_indent = None
-            p.paragraph_format.line_spacing = 1.2
-            add_inline(p, val, size=Pt(10.5), cn_font="宋体")
-            if ri == 0:
-                for run in p.runs:
-                    run.bold = True
-
-
-# ───────────────────────── 图片 + 图题 ─────────────────────────
-
-# ![caption](path)  或  ![](path)
-_IMAGE_LINE_RE = re.compile(r"^\s*!\[(?P[^\]]*)\]\((?P[^)\s]+)\)\s*$")
-
-# mermaid 块里第一行  %% caption: 关键技术关系架构
-_MERMAID_CAPTION_RE = re.compile(r"^\s*%%\s*caption\s*:\s*(.+?)\s*$", re.IGNORECASE)
-_FILENAME_INVALID_RE = re.compile(r"[^一-鿿A-Za-z0-9]+")
-
-# 申报书正文最大图宽 (A4 - 左 3 - 右 2 = 16 cm,留 1 cm 边距更稳)
-_MAX_IMG_WIDTH = Cm(15)
-
-
-def caption_to_stem(caption: str) -> str:
-    """与 render_diagrams.caption_to_stem 同规则: 清洗后取 'fig_'。"""
-    cleaned = _FILENAME_INVALID_RE.sub("_", caption).strip("_")[:40]
-    if not cleaned:
-        return ""
-    return f"fig_{cleaned}"
-
-
-def extract_mermaid_caption(source: str) -> str | None:
-    for ln in source.splitlines():
-        m = _MERMAID_CAPTION_RE.match(ln)
-        if m:
-            return m.group(1).strip()
-    return None
-
-
-def _resolve_image_path(src: str, base_dir: Path) -> Path | None:
-    """图片相对路径以 base_dir (单个 .md 所在目录) 为锚。"""
-    p = Path(src)
-    if not p.is_absolute():
-        p = (base_dir / p).resolve()
-    return p if p.is_file() else None
-
-
-def add_image(doc: Document, png_path: Path, caption: str | None, ctx: dict) -> None:
-    """居中插入图片 + 图题 (五号宋体居中, 编号自增)。"""
-    p = doc.add_paragraph()
-    p.alignment = WD_ALIGN_PARAGRAPH.CENTER
-    p.paragraph_format.first_line_indent = None
-    p.paragraph_format.space_before = Pt(6)
-    p.paragraph_format.space_after = Pt(3)
-    run = p.add_run()
-    try:
-        run.add_picture(str(png_path), width=_MAX_IMG_WIDTH)
-    except Exception as e:
-        # 图片坏了不让整个 doc 崩, 退化成一条占位文字
-        run.add_text(f"[图片插入失败: {png_path.name}: {e}]")
-        return
-
-    ctx["fig_no"] = ctx.get("fig_no", 0) + 1
-    cap_p = doc.add_paragraph()
-    cap_p.alignment = WD_ALIGN_PARAGRAPH.CENTER
-    cap_p.paragraph_format.first_line_indent = None
-    cap_p.paragraph_format.space_before = Pt(0)
-    cap_p.paragraph_format.space_after = Pt(6)
-    cap_text = f"图 {ctx['fig_no']}  {caption}" if caption else f"图 {ctx['fig_no']}"
-    cap_run = cap_p.add_run(cap_text)
-    cap_run.font.size = Pt(10.5)  # 五号
-    cap_run.bold = True
-    _set_run_fonts(cap_run, cn_font="宋体", en_font="Times New Roman")
-
-
-# ───────────────────────── 主渲染 ─────────────────────────
-
-def render_md_block(doc: Document, md_text: str, ctx: dict) -> None:
-    lines = md_text.splitlines()
-    i = 0
-    n = len(lines)
-    while i < n:
-        line = lines[i].rstrip()
-
-        # 空行
-        if not line.strip():
-            i += 1
-            continue
-
-        # 横线
-        if is_hr(line):
-            i += 1
-            continue
-
-        # 图片 ![caption](path.png) — 单独成行
-        m_img = _IMAGE_LINE_RE.match(line)
-        if m_img:
-            src = m_img.group("src")
-            cap = m_img.group("cap").strip() or None
-            png = _resolve_image_path(src, ctx["sections_dir"])
-            if png is not None:
-                add_image(doc, png, cap, ctx)
-            else:
-                # 找不到图片源: 留占位段防止 silently miss
-                add_body_paragraph(doc, f"[图片缺失: {src}]", indent=False)
-            i += 1
-            continue
-
-        # fenced 代码块 / ASCII 流程图 / mermaid (```...``` 或 ~~~...~~~)
-        m_fence = _FENCE_RE.match(line)
-        if m_fence:
-            fence = m_fence.group(1)
-            lang = m_fence.group(2) or ""
-            code: list[str] = []
-            i += 1
-            while i < n:
-                m_close = _FENCE_RE.match(lines[i])
-                # 闭围栏: 同种符号 (` vs ~) 且长度 ≥ 开围栏
-                if m_close and m_close.group(1)[0] == fence[0] and len(m_close.group(1)) >= len(fence):
-                    i += 1
-                    break
-                code.append(lines[i])  # 不 rstrip, 保留原始空格
-                i += 1
-
-            # mermaid 块: 按 caption 清洗后查 figures/fig_.png
-            if lang.lower() == "mermaid":
-                source = "\n".join(code)
-                cap = extract_mermaid_caption(source)
-                if cap:
-                    stem = caption_to_stem(cap)
-                    if stem:
-                        png = ctx["figures_dir"] / f"{stem}.png"
-                        if png.is_file():
-                            add_image(doc, png, cap, ctx)
-                            continue
-                # else fall through to ASCII fallback (无 caption / 未渲染)
-            add_code_block(doc, code, lang)
-            continue
-
-        # 表格 (连续若干行 | ... | 视为一张表)
-        if is_table_line(line):
-            block: list[str] = []
-            while i < n and is_table_line(lines[i]):
-                block.append(lines[i])
-                i += 1
-            render_table(doc, block)
-            continue
-
-        # 标题
-        m = _HEADING_RE.match(line)
-        if m:
-            level = min(len(m.group(1)), 3)
-            add_heading(doc, m.group(2).strip(), level)
-            i += 1
-            continue
-
-        # 引用块 — 模板里多用作"写作提示", 不入正稿
-        if is_blockquote(line):
-            i += 1
-            continue
-
-        # 列表项 (含引文 [N]) — 各自独立成段, 不缩进首行
-        if is_list_item(line):
-            add_body_paragraph(doc, line.strip(), indent=False)
-            i += 1
-            continue
-
-        # 散文段落 — 合并下一空行 / 特殊行前的连续行
-        buf = [line.strip()]
-        j = i + 1
-        while j < n:
-            nxt = lines[j].rstrip()
-            if not nxt.strip():
-                break
-            if is_heading(nxt) or is_blockquote(nxt) or is_table_line(nxt) or is_list_item(nxt) or is_hr(nxt):
-                break
-            buf.append(nxt.strip())
-            j += 1
-        add_body_paragraph(doc, " ".join(buf), indent=True)
-        i = j
-
-
-# ───────────────────────── 入口 ─────────────────────────
-
-def render_sections(sections_dir: Path, out: Path, fund_type: str) -> None:
-    if not sections_dir.is_dir():
-        print(f"[ERR] sections dir not found: {sections_dir}", file=sys.stderr)
-        sys.exit(2)
-    md_files = sorted(sections_dir.glob("*.md"))
-    if not md_files:
-        print(f"[ERR] no .md found in {sections_dir}", file=sys.stderr)
-        sys.exit(2)
-
-    figures_dir = sections_dir.parent / "figures"
-    ctx: dict = {
-        "sections_dir": sections_dir,
-        "figures_dir": figures_dir,
-        "fig_no": 0,
-    }
-
-    doc = init_doc()
-    add_toc(doc)
-    for f in md_files:
-        text = f.read_text(encoding="utf-8")
-        render_md_block(doc, text, ctx)
-        doc.add_page_break()
-
-    out.parent.mkdir(parents=True, exist_ok=True)
-    doc.save(str(out))
-
-    paras = sum(1 for _ in doc.paragraphs)
-    chars = sum(len(p.text) for p in doc.paragraphs)
-    tbls = len(doc.tables)
-    print(f"[OK] rendered {len(md_files)} sections -> {out}")
-    print(f"   paragraphs: {paras} | tables: {tbls} | figures: {ctx['fig_no']} | total chars: {chars}")
-    print(f"   fund_type:  {fund_type}")
-    print(f"   font: 中文宋体小四 / 英文 Times New Roman 小四 / 行距 1.5 / 首行缩进 2 字符")
-    print(f"   提示: 在 Word 中打开后按 F9 (或右键目录 -> 更新域) 生成实际目录。")
-
-
-def main() -> None:
-    ap = argparse.ArgumentParser(description="渲染章节 md → 申报书 docx")
-    ap.add_argument("sections_dir", type=Path, help="sections/*.md 目录")
-    ap.add_argument(
-        "--fund-type",
-        required=True,
-        choices=["key_rd", "major_project", "nsfc_joint_fund",
-                 "nsfc_general", "nsfc_youth", "provincial", "enterprise"],
-    )
-    ap.add_argument("-o", "--output", type=Path, required=True, help="输出 .docx 路径")
-    args = ap.parse_args()
-    render_sections(args.sections_dir, args.output, args.fund_type)
-
-
-if __name__ == "__main__":
-    main()
diff --git a/skills/standard/SKILL.md b/skills/standard/SKILL.md
index e3645c4..6e1ae81 100644
--- a/skills/standard/SKILL.md
+++ b/skills/standard/SKILL.md
@@ -20,7 +20,7 @@ description: 撰写中国标准文件(国家标准 GB/GB·T、行业标准 JC·T
 - `/templates/test_method.md` —— **试验方法标准**章节骨架(GB/T 20001.4,建材院+CSTM 主场)
 - `/templates/product_standard.md` —— **产品标准**章节骨架(分类→技术要求→试验方法→检验规则→标志包装)
 - `/templates/drafting_note.md` —— **编制说明**骨架(报批必交件)
-- **渲染脚本复用 proposal skill**:`/../proposal/scripts/render_diagrams.py` + `render_docx.py` —— 同样的 markdown + ```mermaid``` + `%% caption:` 约定,不另写
+- **渲染复用平台层 + proposal 图脚本**:docx 调 `rendering/render.py --profile proposal`(见下);mermaid 图仍用 `/../proposal/scripts/render_diagrams.py` —— 同样的 markdown + ```mermaid``` + `%% caption:` 约定,不另写
 
 ## 触发 / 不触发
 
@@ -99,11 +99,11 @@ read /references/drafting_rules.md   # 看 §8 自检清单(要素齐
 # 2. mermaid 图预渲染 (章节有 ```mermaid``` 块才跑)
 python /../proposal/scripts/render_diagrams.py /sections/
 
-# 3. 渲染标准正文 .docx (复用 proposal 脚本,--fund-type 只影响打印文案不影响排版)
-python /../proposal/scripts/render_docx.py /sections/ --fund-type key_rd -o /<标准名称>.docx
+# 3. 渲染标准正文 .docx (调平台渲染层,复用 proposal profile;--fund-type 只影响打印文案不影响排版)
+python /sandbox/rendering/render.py --profile proposal --format docx /sections/ --fund-type key_rd -o /<标准名称>.docx
 
 # 4. 渲染编制说明 .docx (有 note_sections/ 时)
-python /../proposal/scripts/render_docx.py /note_sections/ --fund-type key_rd -o /<标准名称>_编制说明.docx
+python /sandbox/rendering/render.py --profile proposal --format docx /note_sections/ --fund-type key_rd -o /<标准名称>_编制说明.docx
 ```
 
 > ⚠️ 不要用 proposal 的 `quality_check.py` —— 它按申报书固定章节名(00_basic_info…)查"缺章节",对标准是误报。结构核对走 drafting_rules.md §8 人工清单(对标准更贴),与 `patent` skill 同一思路。
diff --git a/tests/test_rendering.py b/tests/test_rendering.py
new file mode 100644
index 0000000..774a043
--- /dev/null
+++ b/tests/test_rendering.py
@@ -0,0 +1,104 @@
+"""平台渲染层 rendering/ 守护测试。
+
+防回归 + 防漂移:
+- 三 profile docx 渲染端到端跑通(段落>0、表格==1)
+- 化学式白名单单一事实源(pdf 与 docx 共用 common.CHEM_RE,且能正确下标 + 不误伤 LC3)
+- pdf HTML 生成链(md→HTML→_enrich_html)在不依赖 chromium 下可验
+
+不验 pdf 的 chromium 那步(需沙盒 chromium);那条走 deploy/sandbox/probe_chromium_pdf.sh。
+"""
+from __future__ import annotations
+
+import sys
+import tempfile
+import unittest
+import zipfile
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+
+from rendering import common, docx_brief, docx_manuscript, pdf  # noqa: E402
+
+_SAMPLE = """# 方向标题
+
+## 背景
+
+面向**低碳水泥**,化学式 CO2 / C3S / Na2O,不误伤 LC3、EN 197-5、2026。
+
+- 列表项一
+1. 有序一
+
+[1] 文献. 作者, 刊, 2026. DOI: 10.1016/j.cemconres.2026.107891
+
+| 期刊 | 入选 |
+|---|---|
+| Cement and Concrete Research | 11 |
+
+> 引用块提示。
+"""
+
+
+def _write_sections(d: Path) -> Path:
+    sec = d / "sections"
+    sec.mkdir()
+    (sec / "00.md").write_text(_SAMPLE, encoding="utf-8")
+    return sec
+
+
+def _docx_paragraphs(path: Path) -> int:
+    from docx import Document
+    return sum(1 for _ in Document(str(path)).paragraphs)
+
+
+class TestDocxProfiles(unittest.TestCase):
+    def test_three_profiles_render(self):
+        with tempfile.TemporaryDirectory() as td:
+            d = Path(td)
+            sec = _write_sections(d)
+            # brief
+            out_b = d / "brief.docx"
+            docx_brief.render_sections(sec, out_b, color=True)
+            self.assertTrue(out_b.exists() and out_b.stat().st_size > 0)
+            self.assertGreater(_docx_paragraphs(out_b), 0)
+            # paper
+            out_p = d / "paper.docx"
+            docx_manuscript.render_sections("paper", sec, out_p, lang="zh")
+            self.assertTrue(out_p.exists() and out_p.stat().st_size > 0)
+            # proposal
+            out_r = d / "proposal.docx"
+            docx_manuscript.render_sections("proposal", sec, out_r, fund_type="key_rd")
+            self.assertTrue(out_r.exists() and out_r.stat().st_size > 0)
+            # 每份都应有 1 张表
+            for f in (out_b, out_p, out_r):
+                with zipfile.ZipFile(f) as z:
+                    self.assertIn("word/document.xml", z.namelist())
+
+
+class TestChemSingleSource(unittest.TestCase):
+    def test_pdf_uses_common_chem(self):
+        # 单一事实源:pdf 不得自带白名单,必须复用 common.CHEM_RE
+        self.assertIs(pdf.CHEM_RE, common.CHEM_RE)
+
+    def test_chem_whitelist_hits_and_misses(self):
+        self.assertEqual(common.CHEM_RE.findall("CO2"), ["CO2"])
+        self.assertTrue(common.CHEM_RE.search("Na2SO4"))
+        # 不误伤:LC3 / EN 197-5 / 2026 不应整体命中
+        self.assertIsNone(common.CHEM_RE.fullmatch("LC3"))
+        self.assertNotIn("2026", common.CHEM_RE.findall("the year 2026"))
+
+
+class TestPdfHtmlPipeline(unittest.TestCase):
+    def test_enrich_subscript_and_links(self):
+        html = pdf._enrich_html("

CO2 见 10.1016/j.x 与 example.com/a

") + self.assertIn("CO2", html) + self.assertIn('href="https://doi.org/10.1016/j.x"', html) + self.assertIn('href="https://example.com/a"', html) + + def test_enrich_skips_code_and_links(self): + html = pdf._enrich_html('CO2
CO2') + # code / a 内的 CO2 不下标 + self.assertNotIn("CO2", html) + + +if __name__ == "__main__": + unittest.main()