Compare commits

..

No commits in common. "336db63a01b7c14c5060d727d8d7aaf9c10fdffd" and "247a887cd611e8a4656c32d43d95a4e7b17c31f1" have entirely different histories.

23 changed files with 927 additions and 752 deletions

View File

@ -642,20 +642,6 @@ 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)

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-06-23(平台渲染层 rendering/:三 skill docx 统一 + chromium md→pdf + bump 0.21.0)
最后更新:2026-06-22(前端修复:定时弹窗 z-index 遮挡 + 登录 focus 引用错 id + bump 0.20.3)
---
@ -21,23 +21,6 @@
## 已完成关键能力
### 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)。
- 修复:跳转改 `block:"start"`(顶部对齐,与活跃判定同锚点)+ `.msg``scroll-margin-top:16px` 留呼吸;活跃容差 80→24 与之对齐,贴顶短轮判到自己不越界。
- 文件:`web/static/js/chat.js`(`jumpToMessage` / `updateActiveOutlineDot`)、`web/static/dev.html`(`.msg` CSS);`core/__init__.py` 0.20.3→0.20.4。
### 2026-06-22 / 前端两处 bug 修复(bump 0.20.3)
- 定时弹窗"被遮挡":`#crons-modal` 漏了 z-index,退回基础 `.modal`(无 z-index)被 z-index:5 的侧栏/面板盖住;补 `z-index: 112` 与兄弟只读 modal(`#skills-modal`/`#memory-modal`)对齐。排查用 node 加 DOM mock 跑通整条前端模块图,确认 `hd-crons` 绑定确实执行(排除了"按钮没绑事件"),定位到纯 CSS 层叠问题。

1
RUN.md
View File

@ -301,7 +301,6 @@ 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(分层折叠刷新,直观)。

View File

@ -57,7 +57,7 @@ zcbot 的"skill"是一份可加载的工作流脚本(`skills/<name>/SKILL.md` +
- **引文三角核验**(`citation_verify.md`,移植 ARS 思路、后端换成自有 documents/research 库):存在性 → 三角印证 → 支撑度(抓原文比对 ≤25 词锚点,partial 就改论断迁就证据),编造引文零容忍
- "先定图表再写正文"纪律(接 plot_pub 出 figure)+ 文献矩阵立证据底座
- 写作顺序 Methods→Results→Intro→Discussion→Abstract→Title;关键章一段一卡 + 预告下一段
- `quality_check.py`:结构 / 占位符 / 过度宣称 + **引文交叉核对**(orphan / uncited / 编号连续);docx/pdf 调平台渲染层 `rendering/render.py --profile paper`(中英字体切换 + 图题自增);`word_count.py` 按类型 × 语言核篇幅
- `quality_check.py`:结构 / 占位符 / 过度宣称 + **引文交叉核对**(orphan / uncited / 编号连续);`render_docx.py` 中英字体切换 + 图题自增;`word_count.py` 按类型 × 语言核篇幅
- 终审复用 review skill 的反谄媚审稿协议;可选出 cover letter / AI 声明 / CRediT
**典型产物**:`<topic>.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 等缩写展开)
- **每篇带摘要概述**:列表不只标题,每篇 24 句讲研究对象/方法/主要发现,基于 abstract 或全文、不夸张不评判
- **引文核验**:存在性 / DOI 真伪(以库返回字段为准)/ 支撑度(摘要概述与原文一致,partial 改概述迁就证据),编造零容忍
- **平台渲染层 `rendering/render.py --profile brief`**(docx/pdf):商务红主题 + 论文列表 `[n]` 作锚点、正文 `[n]`/`[Wn]` 引文上标回链 + DOI/URL 可点击超链接(条目内 DOI 子串也链)+ 化学式下标(CO₂/C₃S...,白名单不误伤 LC3/Ca2+);pdf 走沙盒 chromium;做 deck 转 ppt
- **自带 `render_docx.py`**:商务红主题 + 论文列表 `[n]` 作锚点、正文 `[n]`/`[Wn]` 引文上标回链 + DOI/URL 可点击超链接(条目内 DOI 子串也链)+ 化学式下标(CO₂/C₃S...,白名单不误伤 LC3/Ca2+);做 deck 转 ppt
**典型产物**:`<方向>-简报.md`(默认,含 `01_papers` 重要论文列表 + `02_summary` 内容总结)+ `evidence.md`(证据表);可选转 docx / deck。

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。
__version__ = "0.21.0"
__version__ = "0.20.3"

View File

@ -236,11 +236,6 @@ 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)

View File

@ -1,66 +0,0 @@
#!/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'
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8"><style>
@page { size: A4; margin: 2cm; }
body { font-family: 'Noto Sans CJK SC','Noto Serif CJK SC',serif; font-size:12pt; }
h1 { color:#C00000; border-bottom:2px solid #C00000; }
th { background:#C00000; color:#fff; -webkit-print-color-adjust:exact; print-color-adjust:exact; }
td,th { border:1px solid #999; padding:4pt 8pt; }
a { color:#1155CC; }
sub { font-size:0.75em; }
</style></head><body>
<h1>水泥科研方向 — 冒烟测试</h1>
<p>中文渲染、化学式 CO<sub>2</sub> / C<sub>3</sub>S、<a href="https://doi.org/10.1016/x">DOI 超链接</a>。</p>
<table><tr><th>期刊</th><th>篇数</th></tr><tr><td>Cement and Concrete Research</td><td>11</td></tr></table>
</body></html>
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/字体"

View File

@ -1,11 +0,0 @@
"""平台渲染层:把 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 mdHTML沙盒 chromium --print-to-pdf
- render.py 统一入口:--profile {brief,paper,proposal} --format {docx,pdf}
"""

View File

@ -1,143 +0,0 @@
"""平台渲染层 · 共享叶子原语(docx 三 profile + 部分 pdf 复用)。
**真正同源 profile 无关**的底层件:字体 OOXML 助手化学式下标白名单
内联/块级 markdown 正则表格行切分图片路径解析三套 docx profile
(manuscript=paper/proposalbrief) 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<bold>\*\*(?P<bold_t>[^*\n]+?)\*\*)"
r"|(?P<italic>(?<![\*\w])\*(?P<italic_t>[^*\n]+?)\*(?!\*))"
r"|(?P<code>`(?P<code_t>[^`\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<cap>[^\]]*)\]\((?P<src>[^)\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

View File

@ -1,177 +0,0 @@
"""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"(?<![\w/@.])((?:https?://)?[a-z0-9][\w.\-]*\.[a-z]{2,}(?:/[^\s<>\"]*)?)",
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"<sub>\1</sub>", 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'<a href="https://doi.org/{doi}">{doi}</a>'
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'<a href="{href}">{raw}</a>'
out_parts.append(_URL_TOKEN_RE.sub(url_repl, piece))
return "".join(out_parts)
def _enrich_html(html: str) -> str:
"""对 HTML 纯文本片段做化学式下标 + DOI/URL 超链;<a>/<code>/<pre> 内不动。"""
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'<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">'
f"<style>{_css(color)}</style></head><body>{body}</body></html>")
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

View File

@ -1,63 +0,0 @@
"""平台渲染统一入口。各 skill 出 docx/pdf 都调这一个,不再自带 render 脚本。
用法(沙盒内 / host ):
python /sandbox/rendering/render.py --profile brief --format docx <sections> -o out.docx
python /sandbox/rendering/render.py --profile brief --format pdf <sections> -o out.pdf
python /sandbox/rendering/render.py --profile paper --format docx <sections> --lang zh -o out.docx
python /sandbox/rendering/render.py --profile proposal --format docx <sections> --fund-type key_rd -o out.docx
--no-color 出黑白(brief docx / 任意 pdf 生效)<sections> 可为目录(拼接其 *.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 恒在 <root>/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())

View File

@ -7,7 +7,6 @@ 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

View File

@ -23,9 +23,9 @@ description: 生成科研方向简报(research direction briefing / 重要文献
## 资源(路径相对 `load_skill` 头里的 `dir=<绝对路径>`)
- `references/journals.md` —— 各建材子领域主流期刊清单(Elsevier 数据库优先)+ 精确 `publication_name` + 0 命中降级法。**阶段二必读**。
- **平台渲染层 `/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。**
- `scripts/render_docx.py` —— md→docx,商务红主题 + 列表 `[n]` 锚点 + 正文 `[n]`/`[Wn]` 引文上标回链 + DOI/URL 可点超链 + 化学式下标白名单(CO2/C3S/Na2O...,不误伤 LC3/C595/Ca2+)。用 `.venv/Scripts/python.exe` 跑。
产物默认 `.md`;要 docx/pdf 调 `render.py --profile brief`;要 deck 转 `ppt` skill。
产物默认 `.md`;要 docx 用 render_docx.py;要 deck 转 `ppt` skill。
## 阶段一:定题对齐(BLOCKING)
@ -62,7 +62,7 @@ for jname in ["Cement and Concrete Research", "Cement and Concrete Composites",
- 汇成证据表 `<task_dir>/evidence.md`:期刊 | 标题 | 第一作者(机构)| 年-月 | 摘要概述 | DOI | 来源(research/documents/web)。
- 跨源去重:同 DOI 一条(documents 全文优先,DOI 记自 research);web 不与论文去重、单列。
> **窗口内 0 篇**:如实告知库内该窗口暂无收录(可能该刊本窗口尚未发文),可用 web 补更近的非论文动向,**不脑补文献**。
> **库时效(必交代)**:research(OpenAlex)约 3 个月索引滞后,"最新"= 库内最新。窗口内 0 篇 → 如实告知库未收录该窗口,可用 web 补更近的非论文动向,**不脑补文献**。
## 阶段三:列清单 + 内容总结(写 `<task_dir>/sections/*.md`)
@ -95,8 +95,7 @@ for jname in ["Cement and Concrete Research", "Cement and Concrete Composites",
## 阶段五:渲染验收
- 用户要 docx → `python /sandbox/rendering/render.py --profile brief --format docx <sections_dir> -o <方向>-简报.docx`(`--no-color` 出黑白);要 deck → 转 ppt。
- 用户要 pdf → `python /sandbox/rendering/render.py --profile brief --format pdf <sections_dir> -o <方向>-简报.pdf`(沙盒内 chromium 渲染,同样 `--no-color` 出黑白)。**别现搓 weasyprint / 现 pip 装包** —— 直接调 render.py。
- 用户要 docx → `.venv/Scripts/python.exe <dir>/scripts/render_docx.py <sections_dir> -o <方向>-简报.docx`(`--no-color` 出黑白);要 deck → 转 ppt。
- 渲染前自查:`[CITE-]`/`<TODO>` 占位是否清干净、正文 `[n]` 与列表 `[n]` 是否对得上(无 orphan)、有没有混进"建议/启示/本院应当"措辞。
- 交付一句话说清:覆盖了哪些期刊、收了多少篇、时间窗、哪些刊本窗口库内无收录。

View File

@ -1,12 +1,21 @@
"""brief 简报体例 docx 渲染器(商务红主题 + 引文上标超链 + callout/底纹边框)。
"""把 sections/*.md 渲染成科研方向简报 .docx(简报体例,区别于 paper 的投稿稿)。
brief 是三 profile 里最富的一支:书签锚点内部/外部超链接引文 [n]/[Wn] 上标回链
参考条目 DOI 超链概览信息带 / TL;DR 卡片 / 判断 callout页脚页码域这些 paper/proposal
都没有, brief 保留自己的渲染层,只从 rendering.common 复用叶子原语(字体/化学式/块级正则/
表格行切分/图片路径)函数体逐字移植自旧 skills/brief/scripts/render_docx.py
相对 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 <sections_dir> -o <out.docx>
python render_docx.py <sections_dir> --no-color -o <out.docx> # 关配色出纯黑白
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
@ -18,24 +27,6 @@ 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" # 商务红主色
@ -46,7 +37,40 @@ LINK_BLUE = "1155CC" # 超链接蓝
TABLE_HEAD_FILL = "C00000"
# ───────────────────────── 低层 OOXML 辅助 ─────────────────────────
# ───────────────────────── 字体 / 低层 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)
def _para_shading(paragraph, fill: str) -> None:
pPr = paragraph._p.get_or_add_pPr()
@ -167,11 +191,26 @@ def init_doc(color: bool) -> Document:
return doc
# ───────────────────────── 内联:bold/italic/code + 引文 + 化学式 ─────────────────────────
# ───────────────────────── 内联:bold/italic/code 切分 ─────────────────────────
_INLINE_RE = re.compile(
r"(?P<bold>\*\*(?P<bold_t>[^*\n]+?)\*\*)"
r"|(?P<italic>(?<![\*\w])\*(?P<italic_t>[^*\n]+?)\*(?!\*))"
r"|(?P<code>`(?P<code_t>[^`\n]+?)`)"
)
# 引文标记 [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:
"""把白名单化学式里的数字渲成下标,其余正常。"""
@ -416,7 +455,15 @@ 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="宋体")
# ───────────────────────── 行类型识别(brief 专属列表模式)─────────────────────────
# ───────────────────────── 行类型识别 ─────────────────────────
_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<cap>[^\]]*)\]\((?P<src>[^)\s]+)\)\s*$")
_MAX_IMG_WIDTH = Cm(15)
_LIST_PATTERNS = [
re.compile(r"^[-*+]\s"),
@ -433,6 +480,14 @@ 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:
@ -470,6 +525,13 @@ 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
@ -621,8 +683,6 @@ 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)
@ -652,5 +712,19 @@ 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" profile: brief | paragraphs: {paras} | tables: {len(doc.tables)} | "
f"figures: {ctx['fig_no']} | chars: {chars} | theme: {'商务红' if color else '黑白'}")
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()

View File

@ -41,7 +41,7 @@ description: 撰写学术期刊投稿论文(中文核心 / 英文 SCI;原创研
**脚本**(`.venv/Scripts/python.exe <skill_dir>/scripts/...`):
- `scripts/render_diagrams.py` —— sections/*.md 的 ```mermaid``` 块 → `figures/fig_<caption>.png`(caption 必填+唯一)
- **平台渲染层 `/sandbox/rendering/render.py --profile paper`**(不再自带 render_docx)—— md→docx,`--lang {zh,en}`(图题 图/Fig.),`--toc`(默认不出目录),自动 `**bold**`/列表/表格/`![](png)` 居中插图 + 图题自增;要 pdf 加 `--format pdf`。**渲染一律调它,别自己手搓。**
- `scripts/render_docx.py` —— md→docx,`--lang {zh,en}`(图题 图/Fig.),`--toc`(默认不出目录),自动 `**bold**`/列表/表格/`![](png)` 居中插图 + 图题自增
- `scripts/word_count.py` —— `--type --lang`,章节篇幅 vs 预算
- `scripts/quality_check.py` —— `--type`,结构/占位符/过度宣称/插图 + **引文交叉核对**(orphan/uncited/编号连续)
@ -149,7 +149,7 @@ spec 定下「类型 + 语言」后,**按 §资源 条件加载**对应的 cite_
python <skill_dir>/scripts/word_count.py <task_dir>/sections/ --type original --lang en
python <skill_dir>/scripts/quality_check.py <task_dir>/sections/ --type original
python <skill_dir>/scripts/render_diagrams.py <task_dir>/sections/ # 有 ```mermaid 块就跑
python /sandbox/rendering/render.py --profile paper --format docx <task_dir>/sections/ --lang en -o <task_dir>/<topic>.docx
python <skill_dir>/scripts/render_docx.py <task_dir>/sections/ --lang en -o <task_dir>/<topic>.docx
```
- `quality_check` 的 orphan/uncited/占位符不通过 → 回头改章节或补阶段五核验,再跑

View File

@ -1,14 +1,20 @@
"""manuscript 体例 docx 渲染器(paper 投稿稿 + proposal 申报书,配置化双 profile)。
"""把 sections/*.md 渲染成期刊投稿稿 .docx (manuscript draft)。
两者原是近亲(~80% 逐字相同),差异收进 PROFILES:页边距 / TOC 标题 / 图题前缀 /
列表多一条"第X条" / sections 循环(toc 是否默认 + 末段是否补分页)函数体移植自
paper/proposal render_docx.py,叶子原语走 rendering.common
proposal/render_docx.py 同源, 差异:
- fund-type; 改用 --lang {zh,en} (默认 en) 标注语言, 仅影响信息打印与首行缩进策略
- 目录 (TOC) 默认**不生成** (期刊投稿稿无需目录); 要草稿带目录加 --toc
- 字体规范保持: 中文宋体小四 / 英文 Times New Roman 小四 / 行距 1.5 / 首行缩进 2 字符
(eastAsia=宋体 只对 CJK 字符生效, 纯英文论文正文走 Times New Roman, 同一套 style 通吃)
profile=paper: --lang {zh,en}(图题前缀 /Fig.),--toc 可选(默认无)
profile=proposal: --fund-type ...(仅打印),始终带 TOC,每段后分页
支持: **加粗** / *斜体* / `等宽`; 列表 / 表格 / ![caption](png) 居中插图 + 图题自增;
```mermaid``` 块按 caption figures/fig_<caption>.png ( render_diagrams.py 预生成)
用法:
python render_docx.py <sections_dir> --lang en -o <out.docx>
python render_docx.py <sections_dir> --lang zh --toc -o <out.docx>
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
@ -19,50 +25,38 @@ from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from docx.shared import Cm, Pt, RGBColor
from . import common
from .common import set_run_fonts, set_style_fonts, set_subscript, CHEM_RE, parse_inline
# ───────────────────────── 字体辅助 ─────────────────────────
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)
# ───────────────────────── 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 _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)
# ───────────────────────── 文档初始化 ─────────────────────────
def init_doc(prof: dict) -> Document:
def init_doc() -> Document:
doc = Document()
section = doc.sections[0]
@ -70,13 +64,13 @@ def init_doc(prof: dict) -> Document:
section.page_width = Cm(21)
section.top_margin = Cm(2.5)
section.bottom_margin = Cm(2.5)
section.left_margin = prof["left_margin"]
section.right_margin = prof["right_margin"]
section.left_margin = Cm(2.5)
section.right_margin = Cm(2.5)
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)
@ -88,7 +82,7 @@ def init_doc(prof: dict) -> 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)
@ -97,16 +91,18 @@ def init_doc(prof: dict) -> Document:
return doc
def add_toc(doc: Document, prof: dict, depth: int = 3) -> None:
# ───────────────────────── TOC (opt-in) ─────────────────────────
def add_toc(doc: Document, 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(prof["toc_title"])
run = p.add_run("Contents")
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
@ -123,7 +119,7 @@ def add_toc(doc: Document, prof: dict, 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 = prof["toc_placeholder"]
placeholder_t.text = "[Press F9 in Word to generate the table of contents]"
run._element.append(fldChar1)
run._element.append(instrText)
run._element.append(fldChar2)
@ -132,7 +128,49 @@ def add_toc(doc: Document, prof: dict, depth: int = 3) -> None:
doc.add_page_break()
# ───────────────────────── 内联(化学式下标)─────────────────────────
# ───────────────────────── 内联 markdown ─────────────────────────
_INLINE_RE = re.compile(
r"(?P<bold>\*\*(?P<bold_t>[^*\n]+?)\*\*)"
r"|(?P<italic>(?<![\*\w])\*(?P<italic_t>[^*\n]+?)\*(?!\*))"
r"|(?P<code>`(?P<code_t>[^`\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。"""
@ -141,12 +179,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):
@ -169,12 +207,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")
# ───────────────────────── 段落 / 标题 / 列表 ─────────────────────────
@ -201,9 +239,47 @@ 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:
@ -215,18 +291,26 @@ 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 = common.split_md_row(ln)
if not cells or common.is_separator_row(cells):
cells = _split_md_row(ln)
if not cells or _is_separator_row(cells):
continue
rows.append(cells)
if not rows:
@ -257,8 +341,10 @@ def render_table(doc: Document, table_lines: list[str]) -> None:
# ───────────────────────── 图片 + 图题 ─────────────────────────
_IMAGE_LINE_RE = re.compile(r"^\s*!\[(?P<cap>[^\]]*)\]\((?P<src>[^)\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:
@ -276,6 +362,13 @@ 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
@ -284,7 +377,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=common.MAX_IMG_WIDTH)
run.add_picture(str(png_path), width=_MAX_IMG_WIDTH)
except Exception as e:
run.add_text(f"[image failed: {png_path.name}: {e}]")
return
@ -300,13 +393,12 @@ 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)
@ -317,15 +409,15 @@ def render_md_block(doc: Document, md_text: str, ctx: dict) -> None:
i += 1
continue
if common.is_hr(line):
if is_hr(line):
i += 1
continue
m_img = common.IMAGE_LINE_RE.match(line)
m_img = _IMAGE_LINE_RE.match(line)
if m_img:
src = m_img.group("src")
cap = m_img.group("cap").strip() or None
png = common.resolve_image_path(src, ctx["sections_dir"])
png = _resolve_image_path(src, ctx["sections_dir"])
if png is not None:
add_image(doc, png, cap, ctx)
else:
@ -333,14 +425,14 @@ def render_md_block(doc: Document, md_text: str, ctx: dict) -> None:
i += 1
continue
m_fence = common.FENCE_RE.match(line)
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 = common.FENCE_RE.match(lines[i])
m_close = _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
@ -360,26 +452,26 @@ def render_md_block(doc: Document, md_text: str, ctx: dict) -> None:
add_code_block(doc, code, lang)
continue
if common.is_table_line(line):
if is_table_line(line):
block: list[str] = []
while i < n and common.is_table_line(lines[i]):
while i < n and is_table_line(lines[i]):
block.append(lines[i])
i += 1
render_table(doc, block)
continue
m = common.HEADING_RE.match(line)
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 common.is_blockquote(line):
if is_blockquote(line):
i += 1
continue
if is_list_item(line, prof):
if is_list_item(line):
add_body_paragraph(doc, line.strip(), indent=False)
i += 1
continue
@ -390,8 +482,7 @@ def render_md_block(doc: Document, md_text: str, ctx: dict) -> None:
nxt = lines[j].rstrip()
if not nxt.strip():
break
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)):
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
@ -401,9 +492,7 @@ def render_md_block(doc: Document, md_text: str, ctx: dict) -> None:
# ───────────────────────── 入口 ─────────────────────────
def render_sections(profile: str, sections_dir: Path, out: Path, *,
lang: str = "en", toc: bool = False, fund_type: str = "") -> None:
prof = PROFILES[profile]
def render_sections(sections_dir: Path, out: Path, lang: str, toc: bool) -> None:
if not sections_dir.is_dir():
print(f"[ERR] sections dir not found: {sections_dir}", file=sys.stderr)
sys.exit(2)
@ -414,20 +503,19 @@ def render_sections(profile: str, sections_dir: Path, out: Path, *,
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.") if profile == "paper" else "",
"fig_label": "" if lang == "zh" else "Fig.",
}
doc = init_doc(prof)
if prof["always_toc"] or toc:
add_toc(doc, prof)
doc = init_doc()
if toc:
add_toc(doc)
for idx, f in enumerate(md_files):
text = f.read_text(encoding="utf-8")
render_md_block(doc, text, ctx)
if prof["trailing_page_break"] or idx != len(md_files) - 1:
if idx != len(md_files) - 1:
doc.add_page_break()
out.parent.mkdir(parents=True, exist_ok=True)
@ -437,5 +525,22 @@ def render_sections(profile: str, sections_dir: Path, out: Path, *,
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" profile: {profile} | paragraphs: {paras} | tables: {tbls} | "
f"figures: {ctx['fig_no']} | chars: {chars}")
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()

View File

@ -17,7 +17,7 @@ description: 撰写中国发明专利技术交底书 (供专利代理师转写
- `<skill_dir>/references/self_check.md` —— 渲染前自查清单(参数/公式一致、逻辑闭环、脱敏、附图)
- `<skill_dir>/templates/spec.md` —— task 级"宪法"模板(案件名 / 技术领域 / 创新点清单 / 检索结论 / 脱敏边界 / 附图清单)
- `<skill_dir>/templates/disclosure.md` —— 交底书 7 章 Markdown 模板,阶段四照抄
- **渲染复用平台层 + proposal 图脚本**:docx 调 `rendering/render.py --profile proposal`(见下);mermaid 图仍用 `skills/proposal/scripts/render_diagrams.py` 预渲染 `figures/fig_<caption>.png` —— 同样的 markdown + ```mermaid``` + `%% caption:` 约定,不另写
- **渲染脚本复用 proposal skill**:`skills/proposal/scripts/render_diagrams.py` + `render_docx.py` —— 跟交底书 md 兼容(同样的 markdown + ```mermaid``` + `%% caption:` 约定),不另写
## 阶段零: 摄取素材 (有 PDF/DOCX/PPTX/XLSX/URL 时才走)
@ -130,8 +130,8 @@ read <skill_dir>/references/self_check.md
# 2. mermaid 附图预渲染 (章节有 ```mermaid``` 块就跑)
python <skill_dir>/../proposal/scripts/render_diagrams.py <task_dir>/sections/
# 3. 渲染 .docx (调平台渲染层,复用 proposal profile)
python /sandbox/rendering/render.py --profile proposal --format docx <task_dir>/sections/ --fund-type key_rd -o <task_dir>/<案件名>_技术交底书.docx
# 3. 渲染 .docx (复用 proposal skill 的脚本,patent 不另写)
python <skill_dir>/../proposal/scripts/render_docx.py <task_dir>/sections/ --fund-type key_rd -o <task_dir>/<案件名>_技术交底书.docx
```
> `render_docx.py``--fund-type` 只影响目录页表头文案与封面,不影响章节解析 —— 交底书复用 `key_rd` 排版规范(国标黑体/宋体/1.5 倍行距)。封面页用户拿到后手动改成"技术交底书"标题,或在 sections/00_封面.md 自定义。

View File

@ -19,7 +19,7 @@ description: 撰写中国科研项目申报书 / 课题任务书 (国家重点
- `<skill_dir>/references/budget_rules.md` —— 间接费用台阶 + B1-B4 表
- `<skill_dir>/templates/spec.md` —— 阶段一八条对齐的固定字段模板 (复制到 task 级 spec 文件,文件名见下文 §阶段一)
- `<skill_dir>/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
- **平台渲染层 `/sandbox/rendering/render.py --profile proposal`**(不再自带 render_docx)—— md→docx,自动加目录 / 解析 `**bold**`/`*italic*`/`` `code` `` / 列表分行 / `![](path)` 居中插图 + 图题自动编号 / 识别 mermaid 块按 caption 查 `figures/fig_<caption>.png`;要 pdf 加 `--format pdf`。**渲染一律调它,别自己手搓。**
- `<skill_dir>/scripts/render_docx.py` —— md→docx,自动加目录 / 解析 `**bold**`/`*italic*`/`` `code` `` / 列表分行 / `![](path)` 居中插图 + 图题自动编号 / 识别 mermaid 块按 caption 查 `figures/fig_<caption>.png`
- `<skill_dir>/scripts/render_diagrams.py` —— sections/*.md 里的 ```mermaid``` 块预渲染成 `<task_dir>/figures/fig_<caption>.png`(caption 必填 + 全 task 唯一,优先 `mmdc`、回退 `mermaid.ink`)
- `<skill_dir>/scripts/word_count.py` —— 章节字数 vs 预算
- `<skill_dir>/scripts/quality_check.py` —— 结构完整性 / 假大空 / 占位符 / 指南覆盖度(`--spec`)/ 插图(无 `![]()` 引用 / ASCII 字符画 / mermaid 缺 caption / caption 撞名)
@ -94,7 +94,7 @@ glob <task_dir>/*-<task_short_id>-*.spec.md → 按文件名字典序排,取最
python <skill_dir>/scripts/word_count.py <task_dir>/sections/ --fund-type key_rd
python <skill_dir>/scripts/quality_check.py <task_dir>/sections/ --fund-type key_rd --spec <task_dir>/<today>-<task_short_id>-<task_name>.spec.md
python <skill_dir>/scripts/render_diagrams.py <task_dir>/sections/ # 章节有 ```mermaid 块就跑
python /sandbox/rendering/render.py --profile proposal --format docx <task_dir>/sections/ --fund-type key_rd -o <task_dir>/<topic>.docx
python <skill_dir>/scripts/render_docx.py <task_dir>/sections/ --fund-type key_rd -o <task_dir>/<topic>.docx
```
`quality_check` 不通过的项回头 edit 章节再跑。

View File

@ -0,0 +1,604 @@
"""把 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 清洗后查
<sections_dir>/../figures/fig_<caption>.png, 命中走图 + 图题;
未命中走 ASCII fallback (等宽字体保留 box drawing)
PNG render_diagrams.py 预生成, 本脚本只做查表 + 插入
用法:
python render_docx.py <sections_dir> --fund-type key_rd -o <out.docx>
"""
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<bold>\*\*(?P<bold_t>[^*\n]+?)\*\*)"
r"|(?P<italic>(?<![\*\w])\*(?P<italic_t>[^*\n]+?)\*(?!\*))"
r"|(?P<code>`(?P<code_t>[^`\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<cap>[^\]]*)\]\((?P<src>[^)\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_<sanitized>'"""
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_<caption>.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()

View File

@ -20,7 +20,7 @@ description: 撰写中国标准文件(国家标准 GB/GB·T、行业标准 JC·T
- `<skill_dir>/templates/test_method.md` —— **试验方法标准**章节骨架(GB/T 20001.4,建材院+CSTM 主场)
- `<skill_dir>/templates/product_standard.md` —— **产品标准**章节骨架(分类→技术要求→试验方法→检验规则→标志包装)
- `<skill_dir>/templates/drafting_note.md` —— **编制说明**骨架(报批必交件)
- **渲染复用平台层 + proposal 图脚本**:docx 调 `rendering/render.py --profile proposal`(见下);mermaid 图仍用 `<skill_dir>/../proposal/scripts/render_diagrams.py` —— 同样的 markdown + ```mermaid``` + `%% caption:` 约定,不另写
- **渲染脚本复用 proposal skill**:`<skill_dir>/../proposal/scripts/render_diagrams.py` + `render_docx.py` —— 同样的 markdown + ```mermaid``` + `%% caption:` 约定,不另写
## 触发 / 不触发
@ -99,11 +99,11 @@ read <skill_dir>/references/drafting_rules.md # 看 §8 自检清单(要素齐
# 2. mermaid 图预渲染 (章节有 ```mermaid``` 块才跑)
python <skill_dir>/../proposal/scripts/render_diagrams.py <task_dir>/sections/
# 3. 渲染标准正文 .docx (调平台渲染层,复用 proposal profile;--fund-type 只影响打印文案不影响排版)
python /sandbox/rendering/render.py --profile proposal --format docx <task_dir>/sections/ --fund-type key_rd -o <task_dir>/<标准名称>.docx
# 3. 渲染标准正文 .docx (复用 proposal 脚本,--fund-type 只影响打印文案不影响排版)
python <skill_dir>/../proposal/scripts/render_docx.py <task_dir>/sections/ --fund-type key_rd -o <task_dir>/<标准名称>.docx
# 4. 渲染编制说明 .docx (有 note_sections/ 时)
python /sandbox/rendering/render.py --profile proposal --format docx <task_dir>/note_sections/ --fund-type key_rd -o <task_dir>/<标准名称>_编制说明.docx
python <skill_dir>/../proposal/scripts/render_docx.py <task_dir>/note_sections/ --fund-type key_rd -o <task_dir>/<标准名称>_编制说明.docx
```
> ⚠️ 不要用 proposal 的 `quality_check.py` —— 它按申报书固定章节名(00_basic_info…)查"缺章节",对标准是误报。结构核对走 drafting_rules.md §8 人工清单(对标准更贴),与 `patent` skill 同一思路。

View File

@ -1,104 +0,0 @@
"""平台渲染层 rendering/ 守护测试。
防回归 + 防漂移:
- profile docx 渲染端到端跑通(段落>0表格==1)
- 化学式白名单单一事实源(pdf docx 共用 common.CHEM_RE,且能正确下标 + 不误伤 LC3)
- pdf HTML 生成链(mdHTML_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,不误伤 LC3EN 197-52026
- 列表项一
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("<p>CO2 见 10.1016/j.x 与 example.com/a</p>")
self.assertIn("CO<sub>2</sub>", 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('<code>CO2</code> <a href="x">CO2</a>')
# code / a 内的 CO2 不下标
self.assertNotIn("CO<sub>2</sub>", html)
if __name__ == "__main__":
unittest.main()

View File

@ -591,7 +591,7 @@
.ol-dot.active::after { background: var(--accent); width: 20px; }
/* 阅读宽度:assistant/system/tool 限到 ~48rem(约 60-80 字/行,长文不至于满屏铺开难回扫);
user 气泡更窄(36rem)。宽屏下提升可读性,窄屏 92% 仍生效(min 取小者) */
.msg { border: 1px solid var(--border); border-radius: var(--r-md); padding: 8px 12px; max-width: min(92%, 48rem); animation: msg-in .22s cubic-bezier(.2,.7,.2,1); scroll-margin-top: 16px; }
.msg { border: 1px solid var(--border); border-radius: var(--r-md); padding: 8px 12px; max-width: min(92%, 48rem); animation: msg-in .22s cubic-bezier(.2,.7,.2,1); }
.msg.user { background: var(--user-bg); align-self: flex-end; max-width: min(92%, 36rem); }
.msg.assistant, .msg.system, .msg.tool, .msg.error { background: var(--asst-bg); align-self: flex-start; }
.msg.error { border-color: var(--accent); background: var(--accent-soft); color: var(--accent); }

View File

@ -532,10 +532,7 @@ async function jumpToMessage(idx) {
card = wrap.querySelector(`.msg[data-idx="${idx}"]`);
}
if (!card) return;
// 顶部对齐(非居中):第一轮上方无内容无法居中、会被钉到顶端,而 updateActiveOutlineDot
// 按「顶线」判活跃轮 —— 两套锚点必须一致,否则贴顶时活跃圆点会越界到下一轮。
// .msg 的 scroll-margin-top 给卡片留一点上方呼吸空间。
card.scrollIntoView({ behavior: "smooth", block: "start" });
card.scrollIntoView({ behavior: "smooth", block: "center" });
card.classList.add("msg-jump-flash");
setTimeout(() => card.classList.remove("msg-jump-flash"), 1200);
setActiveOutlineIdx(idx);
@ -607,9 +604,7 @@ function updateActiveOutlineDot() {
for (const it of (state.outline || [])) {
const card = wrap.querySelector(`.msg[data-idx="${it.idx}"]`);
if (!card) continue;
// 容差与 .msg 的 scroll-margin-top(16px)对齐:贴顶的短第一轮判到自己,不越界
// 到下一轮(80px 太宽:短轮次时下一轮卡片顶也落进带内 → 误高亮第二个圆点)。
if (card.getBoundingClientRect().top - top <= 24) activeIdx = it.idx;
if (card.getBoundingClientRect().top - top <= 80) activeIdx = it.idx;
else break; // outline 升序,首个落在视口下方的之后都更靠下
}
if (activeIdx != null) setActiveOutlineIdx(activeIdx);