Compare commits
2 Commits
247a887cd6
...
336db63a01
| Author | SHA1 | Date |
|---|---|---|
|
|
336db63a01 | |
|
|
d412aa6b24 |
14
DESIGN.md
14
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 服务层不漂移。
|
**前端取舍(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)
|
## 附录:DeepSeek V4 关键事实(2026-04-24)
|
||||||
|
|
|
||||||
19
PROGRESS.md
19
PROGRESS.md
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
||||||
|
|
||||||
最后更新:2026-06-22(前端修复:定时弹窗 z-index 遮挡 + 登录 focus 引用错 id + bump 0.20.3)
|
最后更新:2026-06-23(平台渲染层 rendering/:三 skill docx 统一 + chromium md→pdf + bump 0.21.0)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -21,6 +21,23 @@
|
||||||
|
|
||||||
## 已完成关键能力
|
## 已完成关键能力
|
||||||
|
|
||||||
|
### 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)
|
### 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 层叠问题。
|
- 定时弹窗"被遮挡":`#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
1
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` 验活。要点:
|
脚本顺序写死:`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/ 要等下次重启才生效。
|
- **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`。
|
- **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)。
|
- **镜像源默认: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(分层折叠刷新,直观)。
|
- **进度可见**:step 2 pip 不带 `-q`,部署时能看到装包进度;step 4 docker build 走默认 TTY 进度 UI(分层折叠刷新,直观)。
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ zcbot 的"skill"是一份可加载的工作流脚本(`skills/<name>/SKILL.md` +
|
||||||
- **引文三角核验**(`citation_verify.md`,移植 ARS 思路、后端换成自有 documents/research 库):存在性 → 三角印证 → 支撑度(抓原文比对 ≤25 词锚点,partial 就改论断迁就证据),编造引文零容忍
|
- **引文三角核验**(`citation_verify.md`,移植 ARS 思路、后端换成自有 documents/research 库):存在性 → 三角印证 → 支撑度(抓原文比对 ≤25 词锚点,partial 就改论断迁就证据),编造引文零容忍
|
||||||
- "先定图表再写正文"纪律(接 plot_pub 出 figure)+ 文献矩阵立证据底座
|
- "先定图表再写正文"纪律(接 plot_pub 出 figure)+ 文献矩阵立证据底座
|
||||||
- 写作顺序 Methods→Results→Intro→Discussion→Abstract→Title;关键章一段一卡 + 预告下一段
|
- 写作顺序 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
|
- 终审复用 review skill 的反谄媚审稿协议;可选出 cover letter / AI 声明 / CRediT
|
||||||
|
|
||||||
**典型产物**:`<topic>.docx`(投稿稿)+ sections/ 分章草稿 + `lit_matrix.md`(文献矩阵)+ `CITATIONS.md`(引文核验台账)。
|
**典型产物**:`<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 等缩写展开)
|
- **三路分工 + 去重**:research+documents 取文献(同 DOI 一条、documents 全文优先)、web 单列产业政策动向不混论文总结;中文方向→英文术语转译(SCM/LC3 等缩写展开)
|
||||||
- **每篇带摘要概述**:列表不只标题,每篇 2–4 句讲研究对象/方法/主要发现,基于 abstract 或全文、不夸张不评判
|
- **每篇带摘要概述**:列表不只标题,每篇 2–4 句讲研究对象/方法/主要发现,基于 abstract 或全文、不夸张不评判
|
||||||
- **引文核验**:存在性 / DOI 真伪(以库返回字段为准)/ 支撑度(摘要概述与原文一致,partial 改概述迁就证据),编造零容忍
|
- **引文核验**:存在性 / 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。
|
**典型产物**:`<方向>-简报.md`(默认,含 `01_papers` 重要论文列表 + `02_summary` 内容总结)+ `evidence.md`(证据表);可选转 docx / deck。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||||
# 改版本只动这一行。
|
# 改版本只动这一行。
|
||||||
__version__ = "0.20.3"
|
__version__ = "0.21.0"
|
||||||
|
|
|
||||||
|
|
@ -236,6 +236,11 @@ class SandboxPool:
|
||||||
skills_path = (self.repo_root / "skills").resolve()
|
skills_path = (self.repo_root / "skills").resolve()
|
||||||
if skills_path.is_dir():
|
if skills_path.is_dir():
|
||||||
cmd += ["-v", f"{skills_path}:/sandbox/skills:ro"]
|
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:
|
if self.runtime:
|
||||||
cmd += ["--runtime", self.runtime]
|
cmd += ["--runtime", self.runtime]
|
||||||
cmd.append(self.image)
|
cmd.append(self.image)
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
<!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/字体"
|
||||||
|
|
@ -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}
|
||||||
|
"""
|
||||||
|
|
@ -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<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
|
||||||
|
|
@ -1,21 +1,12 @@
|
||||||
"""把 sections/*.md 渲染成科研方向简报 .docx(简报体例,区别于 paper 的投稿稿)。
|
"""brief 简报体例 docx 渲染器(商务红主题 + 引文上标超链 + callout/底纹边框)。
|
||||||
|
|
||||||
相对 paper/render_docx.py 的简报专属增强:
|
brief 是三 profile 里最富的一支:书签锚点、内部/外部超链接、引文 [n]/[Wn] 上标回链、
|
||||||
- **商务红配色**(主色 #C00000):标题分级染色 + 标题下细色条;TL;DR / 「判断」行做浅红底纹 callout
|
参考条目 DOI 超链、概览信息带 / TL;DR 卡片 / 判断 callout、页脚页码域。这些 paper/proposal
|
||||||
- **引文上标 + 内部超链接**:正文 [1] / [W3] → 上标红色,点击锚到「重要论文列表 / 参考文献」段对应条目
|
都没有,故 brief 保留自己的渲染层,只从 rendering.common 复用叶子原语(字体/化学式/块级正则/
|
||||||
- **论文列表 / 参考文献可点击**:标题含「论文列表 / 文献列表 / 参考文献」的段,行首 [n] 条目作锚点;
|
表格行切分/图片路径)。函数体逐字移植自旧 skills/brief/scripts/render_docx.py。
|
||||||
条目内 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 字符。
|
|
||||||
支持 **加粗** / *斜体* / `等宽` / 列表 / 表格 /  居中插图。
|
|
||||||
|
|
||||||
用法:
|
|
||||||
python render_docx.py <sections_dir> -o <out.docx>
|
|
||||||
python render_docx.py <sections_dir> --no-color -o <out.docx> # 关配色出纯黑白
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import argparse
|
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -27,6 +18,24 @@ from docx.oxml import OxmlElement
|
||||||
from docx.oxml.ns import qn
|
from docx.oxml.ns import qn
|
||||||
from docx.shared import Cm, Pt, RGBColor
|
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" # 商务红主色
|
PRIMARY = "C00000" # 商务红主色
|
||||||
|
|
@ -37,40 +46,7 @@ LINK_BLUE = "1155CC" # 超链接蓝
|
||||||
TABLE_HEAD_FILL = "C00000"
|
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:
|
def _para_shading(paragraph, fill: str) -> None:
|
||||||
pPr = paragraph._p.get_or_add_pPr()
|
pPr = paragraph._p.get_or_add_pPr()
|
||||||
|
|
@ -191,26 +167,11 @@ def init_doc(color: bool) -> Document:
|
||||||
return doc
|
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]
|
# 引文标记 [12] / [W3]
|
||||||
_CITE_RE = re.compile(r"\[(W?\d+)\]")
|
_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:
|
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="宋体")
|
_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 = [
|
_LIST_PATTERNS = [
|
||||||
re.compile(r"^[-*+]\s"),
|
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:
|
def render_table(doc: Document, table_lines: list[str], color: bool) -> None:
|
||||||
rows = []
|
rows = []
|
||||||
for ln in table_lines:
|
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:
|
def add_image(doc: Document, png_path: Path, caption: str | None, ctx: dict) -> None:
|
||||||
p = doc.add_paragraph()
|
p = doc.add_paragraph()
|
||||||
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||||
|
|
@ -683,6 +621,8 @@ def render_md_block(doc: Document, md_text: str, ctx: dict) -> None:
|
||||||
i = j
|
i = j
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────────────────── 入口 ─────────────────────────
|
||||||
|
|
||||||
def render_sections(sections_dir: Path, out: Path, color: bool) -> None:
|
def render_sections(sections_dir: Path, out: Path, color: bool) -> None:
|
||||||
if not sections_dir.is_dir():
|
if not sections_dir.is_dir():
|
||||||
print(f"[ERR] sections dir not found: {sections_dir}", file=sys.stderr)
|
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)
|
paras = sum(1 for _ in doc.paragraphs)
|
||||||
chars = sum(len(p.text) for p in doc.paragraphs)
|
chars = sum(len(p.text) for p in doc.paragraphs)
|
||||||
print(f"[OK] rendered {len(md_files)} sections -> {out}")
|
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" profile: brief | paragraphs: {paras} | tables: {len(doc.tables)} | "
|
||||||
print(f" theme: {'商务红 #C00000' if color else '黑白'} | 引文上标+超链接 | 化学式下标白名单")
|
f"figures: {ctx['fig_no']} | chars: {chars} | theme: {'商务红' 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()
|
|
||||||
|
|
@ -1,20 +1,14 @@
|
||||||
"""把 sections/*.md 渲染成期刊投稿稿 .docx (manuscript draft)。
|
"""manuscript 体例 docx 渲染器(paper 投稿稿 + proposal 申报书,配置化双 profile)。
|
||||||
|
|
||||||
与 proposal/render_docx.py 同源, 差异:
|
两者原是近亲(~80% 逐字相同),差异收进 PROFILES:页边距 / TOC 标题 / 图题前缀 /
|
||||||
- 无 fund-type; 改用 --lang {zh,en} (默认 en) 标注语言, 仅影响信息打印与首行缩进策略
|
列表多一条"第X条" / sections 循环(toc 是否默认 + 末段是否补分页)。函数体移植自
|
||||||
- 目录 (TOC) 默认**不生成** (期刊投稿稿无需目录); 要草稿带目录加 --toc
|
旧 paper/proposal render_docx.py,叶子原语走 rendering.common。
|
||||||
- 字体规范保持: 中文宋体小四 / 英文 Times New Roman 小四 / 行距 1.5 / 首行缩进 2 字符
|
|
||||||
(eastAsia=宋体 只对 CJK 字符生效, 纯英文论文正文走 Times New Roman, 同一套 style 通吃)
|
|
||||||
|
|
||||||
支持: **加粗** / *斜体* / `等宽`; 列表 / 表格 /  居中插图 + 图题自增;
|
profile=paper: --lang {zh,en}(图题前缀 图/Fig.),--toc 可选(默认无)
|
||||||
```mermaid``` 块按 caption 查 figures/fig_<caption>.png (由 render_diagrams.py 预生成)。
|
profile=proposal: --fund-type ...(仅打印),始终带 TOC,每段后分页
|
||||||
|
|
||||||
用法:
|
|
||||||
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
|
from __future__ import annotations
|
||||||
import argparse
|
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -25,38 +19,50 @@ from docx.oxml import OxmlElement
|
||||||
from docx.oxml.ns import qn
|
from docx.oxml.ns import qn
|
||||||
from docx.shared import Cm, Pt, RGBColor
|
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)
|
|
||||||
|
|
||||||
|
|
||||||
def _set_style_fonts(style, *, cn_font: str = "宋体", en_font: str = "Times New Roman") -> None:
|
# ───────────────────────── profile 配置 ─────────────────────────
|
||||||
el = style.element
|
|
||||||
rPr = el.find(qn("w:rPr"))
|
_BASE_LIST_PATTERNS = [
|
||||||
if rPr is None:
|
re.compile(r"^\[\d+\]\s"), # [1]
|
||||||
rPr = OxmlElement("w:rPr")
|
re.compile(r"^[-*+]\s"), # - / * / +
|
||||||
el.insert(0, rPr)
|
re.compile(r"^\d+[\.、.]\s*"), # 1. / 1、 / 1.
|
||||||
rFonts = rPr.find(qn("w:rFonts"))
|
re.compile(r"^\(\d+\)\s*"), # (1)
|
||||||
if rFonts is None:
|
re.compile(r"^(\d+)\s*"), # (1)
|
||||||
rFonts = OxmlElement("w:rFonts")
|
re.compile(r"^[一二三四五六七八九十百千]+[、.\.]"), # 一、
|
||||||
rPr.append(rFonts)
|
re.compile(r"^[((][一二三四五六七八九十百千]+[))]"), # (一)
|
||||||
rFonts.set(qn("w:eastAsia"), cn_font)
|
re.compile(r"^[①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮]"), # ①
|
||||||
rFonts.set(qn("w:ascii"), en_font)
|
]
|
||||||
rFonts.set(qn("w:hAnsi"), en_font)
|
|
||||||
|
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()
|
doc = Document()
|
||||||
|
|
||||||
section = doc.sections[0]
|
section = doc.sections[0]
|
||||||
|
|
@ -64,13 +70,13 @@ def init_doc() -> Document:
|
||||||
section.page_width = Cm(21)
|
section.page_width = Cm(21)
|
||||||
section.top_margin = Cm(2.5)
|
section.top_margin = Cm(2.5)
|
||||||
section.bottom_margin = Cm(2.5)
|
section.bottom_margin = Cm(2.5)
|
||||||
section.left_margin = Cm(2.5)
|
section.left_margin = prof["left_margin"]
|
||||||
section.right_margin = Cm(2.5)
|
section.right_margin = prof["right_margin"]
|
||||||
|
|
||||||
normal = doc.styles["Normal"]
|
normal = doc.styles["Normal"]
|
||||||
normal.font.name = "Times New Roman"
|
normal.font.name = "Times New Roman"
|
||||||
normal.font.size = Pt(12)
|
normal.font.size = Pt(12)
|
||||||
_set_style_fonts(normal, cn_font="宋体")
|
set_style_fonts(normal, cn_font="宋体")
|
||||||
pf = normal.paragraph_format
|
pf = normal.paragraph_format
|
||||||
pf.line_spacing = 1.5
|
pf.line_spacing = 1.5
|
||||||
pf.space_before = Pt(0)
|
pf.space_before = Pt(0)
|
||||||
|
|
@ -82,7 +88,7 @@ def init_doc() -> Document:
|
||||||
h.font.size = sz
|
h.font.size = sz
|
||||||
h.font.bold = True
|
h.font.bold = True
|
||||||
h.font.color.rgb = RGBColor(0, 0, 0)
|
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.line_spacing = 1.5
|
||||||
h.paragraph_format.space_before = Pt(6)
|
h.paragraph_format.space_before = Pt(6)
|
||||||
h.paragraph_format.space_after = Pt(3)
|
h.paragraph_format.space_after = Pt(3)
|
||||||
|
|
@ -91,18 +97,16 @@ def init_doc() -> Document:
|
||||||
return doc
|
return doc
|
||||||
|
|
||||||
|
|
||||||
# ───────────────────────── TOC (opt-in) ─────────────────────────
|
def add_toc(doc: Document, prof: dict, depth: int = 3) -> None:
|
||||||
|
|
||||||
def add_toc(doc: Document, depth: int = 3) -> None:
|
|
||||||
p = doc.add_paragraph()
|
p = doc.add_paragraph()
|
||||||
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||||
p.paragraph_format.first_line_indent = None
|
p.paragraph_format.first_line_indent = None
|
||||||
p.paragraph_format.space_before = Pt(12)
|
p.paragraph_format.space_before = Pt(12)
|
||||||
p.paragraph_format.space_after = Pt(6)
|
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.size = Pt(16)
|
||||||
run.font.bold = True
|
run.font.bold = True
|
||||||
_set_run_fonts(run, cn_font="黑体")
|
set_run_fonts(run, cn_font="黑体")
|
||||||
|
|
||||||
p = doc.add_paragraph()
|
p = doc.add_paragraph()
|
||||||
p.paragraph_format.first_line_indent = None
|
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")
|
fldChar3.set(qn("w:fldCharType"), "end")
|
||||||
placeholder_t = OxmlElement("w:t")
|
placeholder_t = OxmlElement("w:t")
|
||||||
placeholder_t.set(qn("xml:space"), "preserve")
|
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(fldChar1)
|
||||||
run._element.append(instrText)
|
run._element.append(instrText)
|
||||||
run._element.append(fldChar2)
|
run._element.append(fldChar2)
|
||||||
|
|
@ -128,49 +132,7 @@ def add_toc(doc: Document, depth: int = 3) -> None:
|
||||||
doc.add_page_break()
|
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:
|
def _emit_plain_with_chem(paragraph, text: str, *, size, cn_font: str) -> None:
|
||||||
"""plain 段:白名单化学式里的数字渲成下标,其余正常。无命中即一条普通 run。"""
|
"""plain 段:白名单化学式里的数字渲成下标,其余正常。无命中即一条普通 run。"""
|
||||||
|
|
@ -179,12 +141,12 @@ def _emit_plain_with_chem(paragraph, text: str, *, size, cn_font: str) -> None:
|
||||||
return
|
return
|
||||||
r = paragraph.add_run(seg)
|
r = paragraph.add_run(seg)
|
||||||
r.font.size = size
|
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:
|
if sub:
|
||||||
_set_subscript(r)
|
set_subscript(r)
|
||||||
|
|
||||||
pos = 0
|
pos = 0
|
||||||
for m in _CHEM_RE.finditer(text):
|
for m in CHEM_RE.finditer(text):
|
||||||
_run(text[pos:m.start()])
|
_run(text[pos:m.start()])
|
||||||
buf = ""
|
buf = ""
|
||||||
for ch in m.group(0):
|
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
|
run.font.size = size
|
||||||
if style == "bold":
|
if style == "bold":
|
||||||
run.bold = True
|
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":
|
elif style == "italic":
|
||||||
run.italic = True
|
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":
|
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)
|
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:
|
def add_code_block(doc: Document, lines: list[str], lang: str = "") -> None:
|
||||||
for ln in lines:
|
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)
|
pf.space_after = Pt(0)
|
||||||
run = p.add_run(ln if ln else " ")
|
run = p.add_run(ln if ln else " ")
|
||||||
run.font.size = Pt(10.5)
|
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")):
|
for t in run._element.iter(qn("w:t")):
|
||||||
t.set(qn("xml:space"), "preserve")
|
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:
|
def render_table(doc: Document, table_lines: list[str]) -> None:
|
||||||
rows: list[list[str]] = []
|
rows: list[list[str]] = []
|
||||||
for ln in table_lines:
|
for ln in table_lines:
|
||||||
cells = _split_md_row(ln)
|
cells = common.split_md_row(ln)
|
||||||
if not cells or _is_separator_row(cells):
|
if not cells or common.is_separator_row(cells):
|
||||||
continue
|
continue
|
||||||
rows.append(cells)
|
rows.append(cells)
|
||||||
if not rows:
|
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<cap>[^\]]*)\]\((?P<src>[^)\s]+)\)\s*$")
|
|
||||||
_MERMAID_CAPTION_RE = re.compile(r"^\s*%%\s*caption\s*:\s*(.+?)\s*$", re.IGNORECASE)
|
_MERMAID_CAPTION_RE = re.compile(r"^\s*%%\s*caption\s*:\s*(.+?)\s*$", re.IGNORECASE)
|
||||||
_FILENAME_INVALID_RE = re.compile(r"[^一-鿿A-Za-z0-9]+")
|
_FILENAME_INVALID_RE = re.compile(r"[^一-鿿A-Za-z0-9]+")
|
||||||
_MAX_IMG_WIDTH = Cm(15)
|
|
||||||
|
|
||||||
|
|
||||||
def caption_to_stem(caption: str) -> str:
|
def caption_to_stem(caption: str) -> str:
|
||||||
|
|
@ -362,13 +276,6 @@ def extract_mermaid_caption(source: str) -> str | None:
|
||||||
return 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:
|
def add_image(doc: Document, png_path: Path, caption: str | None, ctx: dict) -> None:
|
||||||
p = doc.add_paragraph()
|
p = doc.add_paragraph()
|
||||||
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
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)
|
p.paragraph_format.space_after = Pt(3)
|
||||||
run = p.add_run()
|
run = p.add_run()
|
||||||
try:
|
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:
|
except Exception as e:
|
||||||
run.add_text(f"[image failed: {png_path.name}: {e}]")
|
run.add_text(f"[image failed: {png_path.name}: {e}]")
|
||||||
return
|
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 = cap_p.add_run(cap_text)
|
||||||
cap_run.font.size = Pt(10.5)
|
cap_run.font.size = Pt(10.5)
|
||||||
cap_run.bold = True
|
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:
|
def render_md_block(doc: Document, md_text: str, ctx: dict) -> None:
|
||||||
|
prof = ctx["prof"]
|
||||||
lines = md_text.splitlines()
|
lines = md_text.splitlines()
|
||||||
i = 0
|
i = 0
|
||||||
n = len(lines)
|
n = len(lines)
|
||||||
|
|
@ -409,15 +317,15 @@ def render_md_block(doc: Document, md_text: str, ctx: dict) -> None:
|
||||||
i += 1
|
i += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if is_hr(line):
|
if common.is_hr(line):
|
||||||
i += 1
|
i += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
m_img = _IMAGE_LINE_RE.match(line)
|
m_img = common.IMAGE_LINE_RE.match(line)
|
||||||
if m_img:
|
if m_img:
|
||||||
src = m_img.group("src")
|
src = m_img.group("src")
|
||||||
cap = m_img.group("cap").strip() or None
|
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:
|
if png is not None:
|
||||||
add_image(doc, png, cap, ctx)
|
add_image(doc, png, cap, ctx)
|
||||||
else:
|
else:
|
||||||
|
|
@ -425,14 +333,14 @@ def render_md_block(doc: Document, md_text: str, ctx: dict) -> None:
|
||||||
i += 1
|
i += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
m_fence = _FENCE_RE.match(line)
|
m_fence = common.FENCE_RE.match(line)
|
||||||
if m_fence:
|
if m_fence:
|
||||||
fence = m_fence.group(1)
|
fence = m_fence.group(1)
|
||||||
lang = m_fence.group(2) or ""
|
lang = m_fence.group(2) or ""
|
||||||
code: list[str] = []
|
code: list[str] = []
|
||||||
i += 1
|
i += 1
|
||||||
while i < n:
|
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):
|
if m_close and m_close.group(1)[0] == fence[0] and len(m_close.group(1)) >= len(fence):
|
||||||
i += 1
|
i += 1
|
||||||
break
|
break
|
||||||
|
|
@ -452,26 +360,26 @@ def render_md_block(doc: Document, md_text: str, ctx: dict) -> None:
|
||||||
add_code_block(doc, code, lang)
|
add_code_block(doc, code, lang)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if is_table_line(line):
|
if common.is_table_line(line):
|
||||||
block: list[str] = []
|
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])
|
block.append(lines[i])
|
||||||
i += 1
|
i += 1
|
||||||
render_table(doc, block)
|
render_table(doc, block)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
m = _HEADING_RE.match(line)
|
m = common.HEADING_RE.match(line)
|
||||||
if m:
|
if m:
|
||||||
level = min(len(m.group(1)), 3)
|
level = min(len(m.group(1)), 3)
|
||||||
add_heading(doc, m.group(2).strip(), level)
|
add_heading(doc, m.group(2).strip(), level)
|
||||||
i += 1
|
i += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if is_blockquote(line):
|
if common.is_blockquote(line):
|
||||||
i += 1
|
i += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if is_list_item(line):
|
if is_list_item(line, prof):
|
||||||
add_body_paragraph(doc, line.strip(), indent=False)
|
add_body_paragraph(doc, line.strip(), indent=False)
|
||||||
i += 1
|
i += 1
|
||||||
continue
|
continue
|
||||||
|
|
@ -482,7 +390,8 @@ def render_md_block(doc: Document, md_text: str, ctx: dict) -> None:
|
||||||
nxt = lines[j].rstrip()
|
nxt = lines[j].rstrip()
|
||||||
if not nxt.strip():
|
if not nxt.strip():
|
||||||
break
|
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
|
break
|
||||||
buf.append(nxt.strip())
|
buf.append(nxt.strip())
|
||||||
j += 1
|
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():
|
if not sections_dir.is_dir():
|
||||||
print(f"[ERR] sections dir not found: {sections_dir}", file=sys.stderr)
|
print(f"[ERR] sections dir not found: {sections_dir}", file=sys.stderr)
|
||||||
sys.exit(2)
|
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"
|
figures_dir = sections_dir.parent / "figures"
|
||||||
ctx: dict = {
|
ctx: dict = {
|
||||||
|
"prof": prof,
|
||||||
"sections_dir": sections_dir,
|
"sections_dir": sections_dir,
|
||||||
"figures_dir": figures_dir,
|
"figures_dir": figures_dir,
|
||||||
"fig_no": 0,
|
"fig_no": 0,
|
||||||
"fig_label": "图" if lang == "zh" else "Fig.",
|
"fig_label": ("图" if lang == "zh" else "Fig.") if profile == "paper" else "图",
|
||||||
}
|
}
|
||||||
|
|
||||||
doc = init_doc()
|
doc = init_doc(prof)
|
||||||
if toc:
|
if prof["always_toc"] or toc:
|
||||||
add_toc(doc)
|
add_toc(doc, prof)
|
||||||
for idx, f in enumerate(md_files):
|
for idx, f in enumerate(md_files):
|
||||||
text = f.read_text(encoding="utf-8")
|
text = f.read_text(encoding="utf-8")
|
||||||
render_md_block(doc, text, ctx)
|
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()
|
doc.add_page_break()
|
||||||
|
|
||||||
out.parent.mkdir(parents=True, exist_ok=True)
|
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)
|
chars = sum(len(p.text) for p in doc.paragraphs)
|
||||||
tbls = len(doc.tables)
|
tbls = len(doc.tables)
|
||||||
print(f"[OK] rendered {len(md_files)} sections -> {out}")
|
print(f"[OK] rendered {len(md_files)} sections -> {out}")
|
||||||
print(f" paragraphs: {paras} | tables: {tbls} | figures: {ctx['fig_no']} | total chars: {chars}")
|
print(f" profile: {profile} | paragraphs: {paras} | tables: {tbls} | "
|
||||||
print(f" lang: {lang} | toc: {toc}")
|
f"figures: {ctx['fig_no']} | chars: {chars}")
|
||||||
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()
|
|
||||||
|
|
@ -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"(?<![\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
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
"""平台渲染统一入口。各 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())
|
||||||
|
|
@ -7,6 +7,7 @@ rich>=13.7.0
|
||||||
python-pptx>=0.6.21
|
python-pptx>=0.6.21
|
||||||
python-docx>=1.1.0
|
python-docx>=1.1.0
|
||||||
matplotlib>=3.8.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 阶段零)
|
# 素材摄取: PDF/DOCX/PPTX/XLSX/HTML/URL → Markdown (ppt 阶段零 + proposal 阶段零)
|
||||||
markitdown[pdf,docx,pptx,xlsx]>=0.0.1
|
markitdown[pdf,docx,pptx,xlsx]>=0.0.1
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,9 @@ description: 生成科研方向简报(research direction briefing / 重要文献
|
||||||
## 资源(路径相对 `load_skill` 头里的 `dir=<绝对路径>`)
|
## 资源(路径相对 `load_skill` 头里的 `dir=<绝对路径>`)
|
||||||
|
|
||||||
- `references/journals.md` —— 各建材子领域主流期刊清单(Elsevier 数据库优先)+ 精确 `publication_name` + 0 命中降级法。**阶段二必读**。
|
- `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)
|
## 阶段一:定题对齐(BLOCKING)
|
||||||
|
|
||||||
|
|
@ -62,7 +62,7 @@ for jname in ["Cement and Concrete Research", "Cement and Concrete Composites",
|
||||||
- 汇成证据表 `<task_dir>/evidence.md`:期刊 | 标题 | 第一作者(机构)| 年-月 | 摘要概述 | DOI | 来源(research/documents/web)。
|
- 汇成证据表 `<task_dir>/evidence.md`:期刊 | 标题 | 第一作者(机构)| 年-月 | 摘要概述 | DOI | 来源(research/documents/web)。
|
||||||
- 跨源去重:同 DOI 一条(documents 全文优先,DOI 记自 research);web 不与论文去重、单列。
|
- 跨源去重:同 DOI 一条(documents 全文优先,DOI 记自 research);web 不与论文去重、单列。
|
||||||
|
|
||||||
> **库时效(必交代)**:research(OpenAlex)约 3 个月索引滞后,"最新"= 库内最新。窗口内 0 篇 → 如实告知库未收录该窗口,可用 web 补更近的非论文动向,**不脑补文献**。
|
> **窗口内 0 篇**:如实告知库内该窗口暂无收录(可能该刊本窗口尚未发文),可用 web 补更近的非论文动向,**不脑补文献**。
|
||||||
|
|
||||||
## 阶段三:列清单 + 内容总结(写 `<task_dir>/sections/*.md`)
|
## 阶段三:列清单 + 内容总结(写 `<task_dir>/sections/*.md`)
|
||||||
|
|
||||||
|
|
@ -95,7 +95,8 @@ for jname in ["Cement and Concrete Research", "Cement and Concrete Composites",
|
||||||
|
|
||||||
## 阶段五:渲染验收
|
## 阶段五:渲染验收
|
||||||
|
|
||||||
- 用户要 docx → `.venv/Scripts/python.exe <dir>/scripts/render_docx.py <sections_dir> -o <方向>-简报.docx`(`--no-color` 出黑白);要 deck → 转 ppt。
|
- 用户要 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。
|
||||||
- 渲染前自查:`[CITE-]`/`<TODO>` 占位是否清干净、正文 `[n]` 与列表 `[n]` 是否对得上(无 orphan)、有没有混进"建议/启示/本院应当"措辞。
|
- 渲染前自查:`[CITE-]`/`<TODO>` 占位是否清干净、正文 `[n]` 与列表 `[n]` 是否对得上(无 orphan)、有没有混进"建议/启示/本院应当"措辞。
|
||||||
- 交付一句话说清:覆盖了哪些期刊、收了多少篇、时间窗、哪些刊本窗口库内无收录。
|
- 交付一句话说清:覆盖了哪些期刊、收了多少篇、时间窗、哪些刊本窗口库内无收录。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ description: 撰写学术期刊投稿论文(中文核心 / 英文 SCI;原创研
|
||||||
|
|
||||||
**脚本**(`.venv/Scripts/python.exe <skill_dir>/scripts/...`):
|
**脚本**(`.venv/Scripts/python.exe <skill_dir>/scripts/...`):
|
||||||
- `scripts/render_diagrams.py` —— sections/*.md 的 ```mermaid``` 块 → `figures/fig_<caption>.png`(caption 必填+唯一)
|
- `scripts/render_diagrams.py` —— sections/*.md 的 ```mermaid``` 块 → `figures/fig_<caption>.png`(caption 必填+唯一)
|
||||||
- `scripts/render_docx.py` —— md→docx,`--lang {zh,en}`(图题 图/Fig.),`--toc`(默认不出目录),自动 `**bold**`/列表/表格/`` 居中插图 + 图题自增
|
- **平台渲染层 `/sandbox/rendering/render.py --profile paper`**(不再自带 render_docx)—— md→docx,`--lang {zh,en}`(图题 图/Fig.),`--toc`(默认不出目录),自动 `**bold**`/列表/表格/`` 居中插图 + 图题自增;要 pdf 加 `--format pdf`。**渲染一律调它,别自己手搓。**
|
||||||
- `scripts/word_count.py` —— `--type --lang`,章节篇幅 vs 预算
|
- `scripts/word_count.py` —— `--type --lang`,章节篇幅 vs 预算
|
||||||
- `scripts/quality_check.py` —— `--type`,结构/占位符/过度宣称/插图 + **引文交叉核对**(orphan/uncited/编号连续)
|
- `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/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/quality_check.py <task_dir>/sections/ --type original
|
||||||
python <skill_dir>/scripts/render_diagrams.py <task_dir>/sections/ # 有 ```mermaid 块就跑
|
python <skill_dir>/scripts/render_diagrams.py <task_dir>/sections/ # 有 ```mermaid 块就跑
|
||||||
python <skill_dir>/scripts/render_docx.py <task_dir>/sections/ --lang en -o <task_dir>/<topic>.docx
|
python /sandbox/rendering/render.py --profile paper --format docx <task_dir>/sections/ --lang en -o <task_dir>/<topic>.docx
|
||||||
```
|
```
|
||||||
|
|
||||||
- `quality_check` 的 orphan/uncited/占位符不通过 → 回头改章节或补阶段五核验,再跑
|
- `quality_check` 的 orphan/uncited/占位符不通过 → 回头改章节或补阶段五核验,再跑
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ description: 撰写中国发明专利技术交底书 (供专利代理师转写
|
||||||
- `<skill_dir>/references/self_check.md` —— 渲染前自查清单(参数/公式一致、逻辑闭环、脱敏、附图)
|
- `<skill_dir>/references/self_check.md` —— 渲染前自查清单(参数/公式一致、逻辑闭环、脱敏、附图)
|
||||||
- `<skill_dir>/templates/spec.md` —— task 级"宪法"模板(案件名 / 技术领域 / 创新点清单 / 检索结论 / 脱敏边界 / 附图清单)
|
- `<skill_dir>/templates/spec.md` —— task 级"宪法"模板(案件名 / 技术领域 / 创新点清单 / 检索结论 / 脱敏边界 / 附图清单)
|
||||||
- `<skill_dir>/templates/disclosure.md` —— 交底书 7 章 Markdown 模板,阶段四照抄
|
- `<skill_dir>/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_<caption>.png` —— 同样的 markdown + ```mermaid``` + `%% caption:` 约定,不另写
|
||||||
|
|
||||||
## 阶段零: 摄取素材 (有 PDF/DOCX/PPTX/XLSX/URL 时才走)
|
## 阶段零: 摄取素材 (有 PDF/DOCX/PPTX/XLSX/URL 时才走)
|
||||||
|
|
||||||
|
|
@ -130,8 +130,8 @@ read <skill_dir>/references/self_check.md
|
||||||
# 2. mermaid 附图预渲染 (章节有 ```mermaid``` 块就跑)
|
# 2. mermaid 附图预渲染 (章节有 ```mermaid``` 块就跑)
|
||||||
python <skill_dir>/../proposal/scripts/render_diagrams.py <task_dir>/sections/
|
python <skill_dir>/../proposal/scripts/render_diagrams.py <task_dir>/sections/
|
||||||
|
|
||||||
# 3. 渲染 .docx (复用 proposal skill 的脚本,patent 不另写)
|
# 3. 渲染 .docx (调平台渲染层,复用 proposal profile)
|
||||||
python <skill_dir>/../proposal/scripts/render_docx.py <task_dir>/sections/ --fund-type key_rd -o <task_dir>/<案件名>_技术交底书.docx
|
python /sandbox/rendering/render.py --profile proposal --format docx <task_dir>/sections/ --fund-type key_rd -o <task_dir>/<案件名>_技术交底书.docx
|
||||||
```
|
```
|
||||||
|
|
||||||
> `render_docx.py` 的 `--fund-type` 只影响目录页表头文案与封面,不影响章节解析 —— 交底书复用 `key_rd` 排版规范(国标黑体/宋体/1.5 倍行距)。封面页用户拿到后手动改成"技术交底书"标题,或在 sections/00_封面.md 自定义。
|
> `render_docx.py` 的 `--fund-type` 只影响目录页表头文案与封面,不影响章节解析 —— 交底书复用 `key_rd` 排版规范(国标黑体/宋体/1.5 倍行距)。封面页用户拿到后手动改成"技术交底书"标题,或在 sections/00_封面.md 自定义。
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ description: 撰写中国科研项目申报书 / 课题任务书 (国家重点
|
||||||
- `<skill_dir>/references/budget_rules.md` —— 间接费用台阶 + B1-B4 表
|
- `<skill_dir>/references/budget_rules.md` —— 间接费用台阶 + B1-B4 表
|
||||||
- `<skill_dir>/templates/spec.md` —— 阶段一八条对齐的固定字段模板 (复制到 task 级 spec 文件,文件名见下文 §阶段一)
|
- `<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
|
- `<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
|
||||||
- `<skill_dir>/scripts/render_docx.py` —— md→docx,自动加目录 / 解析 `**bold**`/`*italic*`/`` `code` `` / 列表分行 / `` 居中插图 + 图题自动编号 / 识别 mermaid 块按 caption 查 `figures/fig_<caption>.png`
|
- **平台渲染层 `/sandbox/rendering/render.py --profile proposal`**(不再自带 render_docx)—— md→docx,自动加目录 / 解析 `**bold**`/`*italic*`/`` `code` `` / 列表分行 / `` 居中插图 + 图题自动编号 / 识别 mermaid 块按 caption 查 `figures/fig_<caption>.png`;要 pdf 加 `--format pdf`。**渲染一律调它,别自己手搓。**
|
||||||
- `<skill_dir>/scripts/render_diagrams.py` —— sections/*.md 里的 ```mermaid``` 块预渲染成 `<task_dir>/figures/fig_<caption>.png`(caption 必填 + 全 task 唯一,优先 `mmdc`、回退 `mermaid.ink`)
|
- `<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/word_count.py` —— 章节字数 vs 预算
|
||||||
- `<skill_dir>/scripts/quality_check.py` —— 结构完整性 / 假大空 / 占位符 / 指南覆盖度(`--spec`)/ 插图(无 `![]()` 引用 / ASCII 字符画 / mermaid 缺 caption / caption 撞名)
|
- `<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/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/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 <skill_dir>/scripts/render_diagrams.py <task_dir>/sections/ # 章节有 ```mermaid 块就跑
|
||||||
python <skill_dir>/scripts/render_docx.py <task_dir>/sections/ --fund-type key_rd -o <task_dir>/<topic>.docx
|
python /sandbox/rendering/render.py --profile proposal --format docx <task_dir>/sections/ --fund-type key_rd -o <task_dir>/<topic>.docx
|
||||||
```
|
```
|
||||||
|
|
||||||
`quality_check` 不通过的项回头 edit 章节再跑。
|
`quality_check` 不通过的项回头 edit 章节再跑。
|
||||||
|
|
|
||||||
|
|
@ -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 表格自动识别, 包含分隔行 |---|---|
|
|
||||||
- 图片  居中插入 + 图题自动编号 (图 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
|
|
||||||
|
|
||||||
|
|
||||||
# ───────────────────────── 图片 + 图题 ─────────────────────────
|
|
||||||
|
|
||||||
#  或 
|
|
||||||
_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
|
|
||||||
|
|
||||||
# 图片  — 单独成行
|
|
||||||
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()
|
|
||||||
|
|
@ -20,7 +20,7 @@ description: 撰写中国标准文件(国家标准 GB/GB·T、行业标准 JC·T
|
||||||
- `<skill_dir>/templates/test_method.md` —— **试验方法标准**章节骨架(GB/T 20001.4,建材院+CSTM 主场)
|
- `<skill_dir>/templates/test_method.md` —— **试验方法标准**章节骨架(GB/T 20001.4,建材院+CSTM 主场)
|
||||||
- `<skill_dir>/templates/product_standard.md` —— **产品标准**章节骨架(分类→技术要求→试验方法→检验规则→标志包装)
|
- `<skill_dir>/templates/product_standard.md` —— **产品标准**章节骨架(分类→技术要求→试验方法→检验规则→标志包装)
|
||||||
- `<skill_dir>/templates/drafting_note.md` —— **编制说明**骨架(报批必交件)
|
- `<skill_dir>/templates/drafting_note.md` —— **编制说明**骨架(报批必交件)
|
||||||
- **渲染脚本复用 proposal skill**:`<skill_dir>/../proposal/scripts/render_diagrams.py` + `render_docx.py` —— 同样的 markdown + ```mermaid``` + `%% caption:` 约定,不另写
|
- **渲染复用平台层 + proposal 图脚本**:docx 调 `rendering/render.py --profile proposal`(见下);mermaid 图仍用 `<skill_dir>/../proposal/scripts/render_diagrams.py` —— 同样的 markdown + ```mermaid``` + `%% caption:` 约定,不另写
|
||||||
|
|
||||||
## 触发 / 不触发
|
## 触发 / 不触发
|
||||||
|
|
||||||
|
|
@ -99,11 +99,11 @@ read <skill_dir>/references/drafting_rules.md # 看 §8 自检清单(要素齐
|
||||||
# 2. mermaid 图预渲染 (章节有 ```mermaid``` 块才跑)
|
# 2. mermaid 图预渲染 (章节有 ```mermaid``` 块才跑)
|
||||||
python <skill_dir>/../proposal/scripts/render_diagrams.py <task_dir>/sections/
|
python <skill_dir>/../proposal/scripts/render_diagrams.py <task_dir>/sections/
|
||||||
|
|
||||||
# 3. 渲染标准正文 .docx (复用 proposal 脚本,--fund-type 只影响打印文案不影响排版)
|
# 3. 渲染标准正文 .docx (调平台渲染层,复用 proposal profile;--fund-type 只影响打印文案不影响排版)
|
||||||
python <skill_dir>/../proposal/scripts/render_docx.py <task_dir>/sections/ --fund-type key_rd -o <task_dir>/<标准名称>.docx
|
python /sandbox/rendering/render.py --profile proposal --format docx <task_dir>/sections/ --fund-type key_rd -o <task_dir>/<标准名称>.docx
|
||||||
|
|
||||||
# 4. 渲染编制说明 .docx (有 note_sections/ 时)
|
# 4. 渲染编制说明 .docx (有 note_sections/ 时)
|
||||||
python <skill_dir>/../proposal/scripts/render_docx.py <task_dir>/note_sections/ --fund-type key_rd -o <task_dir>/<标准名称>_编制说明.docx
|
python /sandbox/rendering/render.py --profile proposal --format docx <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 同一思路。
|
> ⚠️ 不要用 proposal 的 `quality_check.py` —— 它按申报书固定章节名(00_basic_info…)查"缺章节",对标准是误报。结构核对走 drafting_rules.md §8 人工清单(对标准更贴),与 `patent` skill 同一思路。
|
||||||
|
|
|
||||||
|
|
@ -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("<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()
|
||||||
|
|
@ -591,7 +591,7 @@
|
||||||
.ol-dot.active::after { background: var(--accent); width: 20px; }
|
.ol-dot.active::after { background: var(--accent); width: 20px; }
|
||||||
/* 阅读宽度:assistant/system/tool 限到 ~48rem(约 60-80 字/行,长文不至于满屏铺开难回扫);
|
/* 阅读宽度:assistant/system/tool 限到 ~48rem(约 60-80 字/行,长文不至于满屏铺开难回扫);
|
||||||
user 气泡更窄(36rem)。宽屏下提升可读性,窄屏 92% 仍生效(min 取小者) */
|
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); }
|
.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.user { background: var(--user-bg); align-self: flex-end; max-width: min(92%, 36rem); }
|
.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.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); }
|
.msg.error { border-color: var(--accent); background: var(--accent-soft); color: var(--accent); }
|
||||||
|
|
|
||||||
|
|
@ -532,7 +532,10 @@ async function jumpToMessage(idx) {
|
||||||
card = wrap.querySelector(`.msg[data-idx="${idx}"]`);
|
card = wrap.querySelector(`.msg[data-idx="${idx}"]`);
|
||||||
}
|
}
|
||||||
if (!card) return;
|
if (!card) return;
|
||||||
card.scrollIntoView({ behavior: "smooth", block: "center" });
|
// 顶部对齐(非居中):第一轮上方无内容无法居中、会被钉到顶端,而 updateActiveOutlineDot
|
||||||
|
// 按「顶线」判活跃轮 —— 两套锚点必须一致,否则贴顶时活跃圆点会越界到下一轮。
|
||||||
|
// .msg 的 scroll-margin-top 给卡片留一点上方呼吸空间。
|
||||||
|
card.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
card.classList.add("msg-jump-flash");
|
card.classList.add("msg-jump-flash");
|
||||||
setTimeout(() => card.classList.remove("msg-jump-flash"), 1200);
|
setTimeout(() => card.classList.remove("msg-jump-flash"), 1200);
|
||||||
setActiveOutlineIdx(idx);
|
setActiveOutlineIdx(idx);
|
||||||
|
|
@ -604,7 +607,9 @@ function updateActiveOutlineDot() {
|
||||||
for (const it of (state.outline || [])) {
|
for (const it of (state.outline || [])) {
|
||||||
const card = wrap.querySelector(`.msg[data-idx="${it.idx}"]`);
|
const card = wrap.querySelector(`.msg[data-idx="${it.idx}"]`);
|
||||||
if (!card) continue;
|
if (!card) continue;
|
||||||
if (card.getBoundingClientRect().top - top <= 80) activeIdx = it.idx;
|
// 容差与 .msg 的 scroll-margin-top(16px)对齐:贴顶的短第一轮判到自己,不越界
|
||||||
|
// 到下一轮(80px 太宽:短轮次时下一轮卡片顶也落进带内 → 误高亮第二个圆点)。
|
||||||
|
if (card.getBoundingClientRect().top - top <= 24) activeIdx = it.idx;
|
||||||
else break; // outline 升序,首个落在视口下方的之后都更靠下
|
else break; // outline 升序,首个落在视口下方的之后都更靠下
|
||||||
}
|
}
|
||||||
if (activeIdx != null) setActiveOutlineIdx(activeIdx);
|
if (activeIdx != null) setActiveOutlineIdx(activeIdx);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue