发布 GB/T 9704-2012 公文排版技能
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 mizzlelover
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Gongwen GB/T 9704-2012 Skill
|
||||
|
||||
一个面向中文正式材料的 Skill:生成参照 GB/T 9704-2012 的公文版式 DOCX,并提供可选的格式核验清单。
|
||||
|
||||
## 为什么需要它
|
||||
|
||||
许多 AI 只会把标题设为黑体、正文设为仿宋,便把输出称为“公文”。本项目把 A4、版心、正文、层次、首页和页码等版式参数做成可生成、可检查的 DOCX 输出,并接受用户提供的机构名称、文号、署名、日期和页码模式。
|
||||
|
||||
## 能力与边界
|
||||
|
||||
| 场景 | 本项目的处理方式 |
|
||||
| --- | --- |
|
||||
| 文档主体 | A4、版心、标题、正文、层次、署名和日期排版 |
|
||||
| 机构名称与文号 | 使用 `--org`、`--doc-no` 按输入排版 |
|
||||
| 页码 | 居中、单双页或不显示 |
|
||||
| 印章、签名章 | 在生成的 DOCX 中按实际需要插入图片或完成实体盖章 |
|
||||
| 特定格式 | 可在此版式基础上继续编辑,或使用对应模板 |
|
||||
|
||||
GB/T 9704-2012 当前为现行标准,2025-05-30 复审继续有效。标准适用于党政机关制发公文,其他机关和单位可以参照执行。[全国标准信息公共服务平台](https://std.samr.gov.cn/gb/search/gbDetailed?id=lOIe27f77QU%3D&mode=p)
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
node scripts/generate_gongwen_docx.mjs --input tests/fixture.md --output /tmp/gongwen.docx --org "示例单位文件" --doc-no "示例发〔2026〕1号" --title "公文格式回归测试" --page-number standard
|
||||
node scripts/verify_gongwen_docx.mjs --input /tmp/gongwen.docx --profile standard-pages
|
||||
```
|
||||
|
||||
然后用 Word 或 WPS 打开,并转换 PDF 检查标题、正文、表格、分页和页码。生成器支持 `center`、`standard` 和 `none` 页码模式,机构名称和文号按输入写入文档。
|
||||
|
||||
## 跨平台安装
|
||||
|
||||
本项目使用开放的 `SKILL.md` 目录结构,可在 Codex、Claude Code、OpenCode、Trae Code、Trae CLI、Kimi Code CLI、Kimi Code、WorkBuddy 和 ZCode 使用。
|
||||
|
||||
macOS / Linux:
|
||||
|
||||
```bash
|
||||
scripts/install.sh --all
|
||||
```
|
||||
|
||||
Windows:
|
||||
|
||||
```powershell
|
||||
.\scripts\install.ps1 -All
|
||||
```
|
||||
|
||||
安装器将同一份源目录链接到各平台的用户级技能目录;Windows 默认复制以避免符号链接权限问题。TraeWork 使用可导入包 [`dist/traework-gongwen-skill.zip`](dist/traework-gongwen-skill.zip)。完整目录、验证方法和官方依据见[跨平台安装说明](docs/platform-support.md)。
|
||||
|
||||
## 项目内容
|
||||
|
||||
- [SKILL.md](SKILL.md):使用指引与版式参数。
|
||||
- [标准执行摘要](references/gbt9704-2012-summary.md):国家标准的可执行要点。
|
||||
- [正式公文核验表](references/formal-checklist.md):模板完成后的全要素检查。
|
||||
- [生成器](scripts/generate_gongwen_docx.mjs):根据输入生成 DOCX 版式。
|
||||
- [校验器](scripts/verify_gongwen_docx.mjs):检查生成器可承诺的 DOCX 版式要素。
|
||||
- [回归测试](tests/run_tests.sh):运行居中与单双页页码生成、DOCX 包结构与 PDF 转换验证。
|
||||
- [跨平台安装测试](tests/test-install.sh):隔离环境中验证九个本地目录和 TraeWork 导入包。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
tests/run_tests.sh
|
||||
```
|
||||
|
||||
这项测试证明生成器、校验器和可打印文件链路可运行。最终文件可按实际用途在目标 Word/WPS 与打印条件中检查。
|
||||
|
||||
## 许可
|
||||
|
||||
MIT。标准文本及机关模板应按各自权利和管理规则取得与使用。
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
name: gongwen
|
||||
description: 将中文正式材料生成参照 GB/T 9704-2012 的公文版式 DOCX,支持机构名称、文号、正文、署名、日期和居中或单双页页码,并提供可选格式检查。
|
||||
---
|
||||
|
||||
# 公文版式工具
|
||||
|
||||
## 标准依据与边界
|
||||
|
||||
GB/T 9704-2012《党政机关公文格式》现行,2025-05-30 复审继续有效。其适用于党政机关制发公文;其他机关、单位可以参照执行。
|
||||
|
||||
先读 `references/gbt9704-2012-summary.md`;需要逐项复核时再读 `references/formal-checklist.md`。标准规定纸张、版面、要素编排和印制装订要求。
|
||||
|
||||
## 工具定位
|
||||
|
||||
本工具只处理排版。它不判断用户材料的用途、单位身份、授权状态或后续盖章方式,也不推断用户未提供的要素。机构名称、文号、主送机关、署名、日期和页码模式均按用户输入排入 DOCX。
|
||||
|
||||
生成器可输出 A4、156mm×225mm 版心、三号仿宋正文、小标宋二号标题、四级标题层次、两字缩进,以及居中或单双页页码。
|
||||
|
||||
`references/formal-checklist.md` 是可选的版式复核清单。涉及印章或签名章时,使用者可在 Word/WPS 中插入已取得的图片或完成实体盖章;本生成器不内置印章图像。
|
||||
|
||||
## 生成打印稿
|
||||
|
||||
```bash
|
||||
node scripts/generate_gongwen_docx.mjs --input source.md --output output.docx --org "发文机关名称" --doc-no "单位发〔2026〕1号" --title "文档标题" --sender "落款单位" --date "2026年9月9日" --page-number standard
|
||||
```
|
||||
|
||||
支持 `center`、`standard` 与 `none` 三种页码模式,默认 `center`。`standard` 使用单页右、双页左的页码位置。`--org` 以红色居中机构名称排版,`--doc-no` 在其下方排版。
|
||||
|
||||
Markdown 标题优先按序数识别:`一、`、`(一)`、`1.`、`(1)`;序数缺失时才按 Markdown 层级映射。正文、四级标题均左空二字,回行顶格。标题字体在运行机器缺失时,会保留请求的字体名;打开文件时仍须确认 Office 的实际替代字体,不把普通宋体视作小标宋的等价替代。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
unzip -t output.docx
|
||||
soffice --headless --convert-to pdf --outdir /tmp output.docx
|
||||
node scripts/verify_gongwen_docx.mjs --input output.docx --profile layout
|
||||
```
|
||||
|
||||
校验器核验生成器可检查的版式要素。`layout` 用于居中页码,`standard-pages` 用于单双页页码。输出后可在目标 Word/WPS 与实际打印条件下检查首页正文、分页、附件、表格、页码和装订。
|
||||
|
||||
## 输出说明
|
||||
|
||||
- 版式检查通过后,可写“已按 GB/T 9704-2012 的相关版式参数生成”。
|
||||
- 使用者可按实际用途决定是否采用 `formal-checklist.md` 逐项复核。
|
||||
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,47 @@
|
||||
# 平台安装与验证
|
||||
|
||||
本项目采用 Agent Skills 通用目录形态:一个以 `SKILL.md` 为入口的目录,附带 `scripts/` 和 `references/`。所有目标平台安装的是同一份源目录,不维护改写版提示词。
|
||||
|
||||
| 平台 | 用户级位置或导入方式 | 验证方式 |
|
||||
| --- | --- | --- |
|
||||
| Codex | `~/.codex/skills/gongwen` | 新会话中确认 `gongwen` 出现在技能列表 |
|
||||
| Claude Code | `~/.claude/skills/gongwen` | 输入 `/gongwen` 或在任务中显式提及技能 |
|
||||
| OpenCode | `~/.config/opencode/skills/gongwen` | 在支持技能的 Agent 中确认 `gongwen` 可加载 |
|
||||
| Trae Code | `~/.trae-cn/skills/gongwen` | 设置中的“技能与命令”面板刷新后确认 |
|
||||
| Trae CLI | `~/.traecli/skills/gongwen` | 重启后执行 `/skills` |
|
||||
| Kimi Code CLI | `~/.kimi/skills/gongwen` | 新会话中执行 `/skill:gongwen` |
|
||||
| Kimi Code | `~/.kimi-code/skills/gongwen` | 新会话中执行 `/skill:gongwen` |
|
||||
| WorkBuddy | `~/.workbuddy/skills/gongwen` | 在技能管理界面刷新并启用 |
|
||||
| ZCode | `~/.zcode/skills/gongwen` | 设置 → Skills 刷新并确认启用 |
|
||||
| TraeWork | 导入 `dist/traework-gongwen-skill.zip` | 导入后在企业技能列表确认启用 |
|
||||
|
||||
## macOS 与 Linux
|
||||
|
||||
```bash
|
||||
git clone https://github.com/mizzlelover/gongwen-gbt9704-skill.git
|
||||
cd gongwen-gbt9704-skill
|
||||
scripts/install.sh --all
|
||||
```
|
||||
|
||||
安装器默认创建符号链接,因此 GitHub 拉取更新后,所有已安装平台都会使用同一份最新技能。若目标已有非链接的 `gongwen`,脚本会拒绝覆盖;核对后才可传入 `--force`。
|
||||
|
||||
## Windows
|
||||
|
||||
```powershell
|
||||
git clone https://github.com/mizzlelover/gongwen-gbt9704-skill.git
|
||||
cd gongwen-gbt9704-skill
|
||||
.\scripts\install.ps1 -All
|
||||
```
|
||||
|
||||
PowerShell 默认复制目录,避免依赖管理员权限或开发者模式。需要实时同步时,使用 `-Link`;已有同名目录需明确传入 `-Force`。
|
||||
|
||||
## 官方目录依据
|
||||
|
||||
- [Claude Code Skills](https://docs.anthropic.com/en/docs/claude-code/skills)
|
||||
- [OpenCode Agent Skills](https://opencode.ai/docs/skills)
|
||||
- [Trae Code 技能](https://docs.trae.cn/ide_skills) 与 [Trae CLI 技能](https://docs.trae.cn/cli_skills)
|
||||
- [Kimi Code CLI Agent Skills](https://moonshotai.github.io/kimi-cli/en/customization/skills)
|
||||
- [ZCode Skills](https://zcode.z.ai/en/docs/skill)
|
||||
- [TraeWork 企业技能](https://docs.trae.cn/enterprise_skills)
|
||||
|
||||
WorkBuddy 公开文档将 `skills/<skill-name>/SKILL.md` 作为技能目录结构;本项目同时保留用户级目录安装,便于本地技能管理器发现。
|
||||
@@ -0,0 +1,40 @@
|
||||
# 正式党政机关公文逐项核验表
|
||||
|
||||
本表按 GB/T 9704-2012 编制,用于机关权威模板完成后的人机协同核验。每项记录“适用/不适用、证据位置、核验人、日期”;不适用应说明原因。它不替代发文机关公文处理制度。
|
||||
|
||||
## 1. 前提与制作
|
||||
|
||||
- [ ] 已确认发文机关、文种、行文关系、主送范围、发文字号、签发权限及机关现行模板。
|
||||
- [ ] A4 成品幅面为 210mm×297mm;天头 37mm±1mm、订口 28mm±1mm、版心 156mm×225mm。
|
||||
- [ ] 无特殊说明时采用三号仿宋;一般每面22行、每行28字并撑满版心。不得把固定28pt当作国标唯一强制值。
|
||||
- [ ] 文本颜色为黑色;需红色的发文机关标志、分隔线、印章或签名章已按规则处理。
|
||||
- [ ] 制作环节已确认双面印刷、左侧装订、页码套正、钉位及成品质量要求;这些项目不能从 DOCX 自动推定。
|
||||
|
||||
## 2. 版头
|
||||
|
||||
- [ ] 份号适用时为6位三号阿拉伯数字,左上角第一行顶格。
|
||||
- [ ] 密级和保密期限、紧急程度适用时均为三号黑体,按份号、密级和期限、紧急程度顺序自上而下排列。
|
||||
- [ ] 发文机关标志使用全称或规范简称,可加“文件”;居中、红色、推荐小标宋,上边缘距版心上边缘35mm。
|
||||
- [ ] 发文字号位于机关标志下空二行;年份为全称阿拉伯数字并使用六角括号〔〕,序号不加“第”、不补零、后加“号”。
|
||||
- [ ] 上行文文号左空一字;签发人同区右空一字。“签发人:”为仿宋三号,姓名为楷体三号;多签发人按机关顺序编排。
|
||||
- [ ] 红色分隔线在文号下4mm处,与版心等宽。
|
||||
|
||||
## 3. 主体
|
||||
|
||||
- [ ] 标题为二号小标宋,红线下空二行居中;多行标题词意完整、排列对称,呈梯形或菱形。
|
||||
- [ ] 主送机关在标题下空一行顶格,回行顶格,最后名称后用全角冒号;过多而影响首页正文时移入版记。
|
||||
- [ ] 首页显示正文;正文一般三号仿宋,每个自然段左空二字、回行顶格。
|
||||
- [ ] 层次序数按“一、”“(一)”“1.”“(1)”使用;第一层黑体、第二层楷体、第三和第四层仿宋,均为三号。
|
||||
- [ ] 附件说明位于正文下空一行、左空二字;“附件:”后名称无标点;多附件编号正确,长名称回行与首字对齐。
|
||||
- [ ] 署名、成文日期和印章已按“加盖印章/不加盖印章/签发人签名章/联合行文”之一完整处理;成文日期年月日用阿拉伯数字全称且月日不补零;没有空白印章或伪造印章。
|
||||
- [ ] 附注适用时在成文日期下一行左空二字,以圆括号标注。
|
||||
- [ ] 附件另面编排、置于版记之前;左上第一行为“附件”及序号黑体三号,标题在第三行居中;与正文一起装订的附件页码连续。
|
||||
|
||||
## 4. 版记、页码与特殊格式
|
||||
|
||||
- [ ] 版记首末分隔线为粗线、中间线为细线;末条线与最后一面版心下边缘重合。
|
||||
- [ ] 抄送机关适用时为四号仿宋、左右各空一字、回行与冒号后首字对齐、末尾句号;主送移入版记时按“主送”规则处理。
|
||||
- [ ] 印发机关和日期为四号仿宋,左空一字与右空一字;日期完整且不补零,后加“印发”。
|
||||
- [ ] 页码为四号半角宋体阿拉伯数字,两侧各一字线,线距版心下边缘7mm;单页右空一字、双页左空一字;空白页、特定版记页和附件连续页码已按规则处理。
|
||||
- [ ] 横排表格、信函、命令(令)、纪要、联合行文已使用相应专用版式;不能用普通打印稿替代。
|
||||
- [ ] 计量单位、标点符号、数字分别按 GB 3100、GB 3101、GB 3102、GB/T 15834、GB/T 15835 复核。
|
||||
@@ -0,0 +1,160 @@
|
||||
# GB/T 9704-2012《党政机关公文格式》执行摘要
|
||||
|
||||
## 一、依据与状态
|
||||
|
||||
- 标准号:GB/T 9704-2012。
|
||||
- 标准名称:党政机关公文格式。
|
||||
- 发布日期:2012年6月29日。
|
||||
- 实施日期:2012年7月1日。
|
||||
- 当前状态:现行;全国标准信息公共服务平台显示2025年5月30日复审结论为继续有效。
|
||||
- 官方信息页:https://openstd.samr.gov.cn/bzgk/std/newGbInfo?hcno=F3CC9BEF482524C895FDA7A08BB4A70E
|
||||
- 标准状态页:https://std.samr.gov.cn/gb/search/gbDetailed?id=lOIe27f77QU%3D&mode=p
|
||||
- 公文处理依据:https://www.gov.cn/zwgk/2013-02/22/content_2337704.htm
|
||||
|
||||
本摘要用于生成和校验,不代替标准原文。工具按用户提供的字段排版,不判断材料用途或替用户补充未提供的信息。
|
||||
|
||||
## 二、常见使用场景
|
||||
|
||||
### 1. 公文版式打印稿
|
||||
|
||||
适用于方案、报告、说明、备忘材料和内部汇报。采用A4纸、标准版心、字号、行距、标题层级和页码,可按输入加入机构名称、文号、主送机关、署名和日期。
|
||||
|
||||
### 2. 正式党政机关公文
|
||||
|
||||
除页面和正文格式外,还可按实际需要处理版头、主体、版记、页码、印章或签名章。生成器排版用户提供的文字字段;印章和签名章可在生成后的 Word/WPS 文件中按需插入。
|
||||
|
||||
## 三、纸张、版面和印制
|
||||
|
||||
| 项目 | 标准口径 | 生成参数 |
|
||||
|---|---|---|
|
||||
| 纸张 | A4,210mm×297mm | 11906×16838 DXA |
|
||||
| 天头 | 37mm±1mm | 上边距2098 DXA |
|
||||
| 订口 | 28mm±1mm | 左边距1588 DXA |
|
||||
| 版心 | 156mm×225mm | 由页边距共同形成 |
|
||||
| 推导白边 | 右约26mm、下约35mm | 右1474、下1984 DXA |
|
||||
| 字体 | 无特殊说明一般3号仿宋 | 16pt,`w:sz=32` |
|
||||
| 行数和字数 | 一般每面22行、每行28字,特定情况可调整 | 标准要求是版面结果;28pt固定行距是常用实现值,不是国标唯一强制参数 |
|
||||
| 文字颜色 | 无特殊说明为黑色 | `000000` |
|
||||
| 印刷 | 正式公文双面印刷,页码套正 | 属打印环节,DOCX无法自动保证 |
|
||||
| 装订 | 左侧装订,正式公文执行钉位等要求 | 属制作环节,需人工检查 |
|
||||
|
||||
## 四、正文主体格式
|
||||
|
||||
### 1. 标题
|
||||
|
||||
- 一般用2号小标宋体,位于红色分隔线下空二行,居中。
|
||||
- 多行标题应词意完整、排列对称,宜呈梯形或菱形。
|
||||
- 公文版式打印稿无红色分隔线时,标题仍用小标宋二号居中,但应根据篇幅控制标题后间距。
|
||||
|
||||
### 2. 主送机关
|
||||
|
||||
- 位于标题下空一行,左顶格;回行仍顶格;末尾用全角冒号。
|
||||
- 主送机关过多导致首页不能显示正文时,移至版记。
|
||||
- 公文首页必须显示正文。
|
||||
|
||||
### 3. 正文
|
||||
|
||||
- 一般用3号仿宋。
|
||||
- 每个自然段左空二字,回行顶格。
|
||||
- 层次序数依次使用“一、”“(一)”“1.”“(1)”。
|
||||
|
||||
| 层级 | 序数示例 | 字体 | 字号 | 段落 |
|
||||
|---|---|---|---|---|
|
||||
| 第一层 | 一、 | 黑体 | 三号 | 左空二字,回行顶格 |
|
||||
| 第二层 | (一) | 楷体 | 三号 | 左空二字,回行顶格 |
|
||||
| 第三层 | 1. | 仿宋 | 三号 | 左空二字,回行顶格 |
|
||||
| 第四层 | (1) | 仿宋 | 三号 | 左空二字,回行顶格 |
|
||||
|
||||
字体名称已经体现字重,不额外对小标宋、黑体、楷体设置Word“加粗”。
|
||||
|
||||
### 4. 附件说明
|
||||
|
||||
- 正文下空一行,左空二字标注“附件:”。
|
||||
- 多个附件用阿拉伯数字排序。
|
||||
- 附件名称后不加标点;长名称回行与名称首字对齐。
|
||||
|
||||
### 5. 发文机关署名、日期和印章
|
||||
|
||||
- 加盖印章:成文日期一般右空四字;印章应为红色并下压机关署名和日期。
|
||||
- 不加印章:正文或附件说明下空一行,机关署名右空二字,下一行编排日期。
|
||||
- 成文日期使用阿拉伯数字完整标注年月日,月、日不补零。
|
||||
- 印章和签名章是独立图片或实体印章对象;本生成器不内置其图像,可在生成后的 Word/WPS 文件中按需插入。
|
||||
|
||||
### 6. 附注与附件正文
|
||||
|
||||
- 附注位于成文日期下一行,左空二字,加圆括号。
|
||||
- 附件另面编排、置于版记之前。“附件”及顺序号用3号黑体顶格置于版心左上角第一行,附件标题在版心第三行居中。
|
||||
- 附件与正文一起装订时页码连续。
|
||||
|
||||
## 五、版头要素
|
||||
|
||||
正式红头公文按需包含:份号、密级和保密期限、紧急程度、发文机关标志、发文字号、签发人和红色分隔线。
|
||||
|
||||
| 要素 | 核心要求 |
|
||||
|---|---|
|
||||
| 份号 | 6位3号阿拉伯数字,版心左上角第一行顶格 |
|
||||
| 密级和期限 | 3号黑体,按规则置于左上区域 |
|
||||
| 紧急程度 | 3号黑体,与份号、密级按顺序排列 |
|
||||
| 发文机关标志 | 机关全称或规范简称,可加“文件”;红色小标宋,居中 |
|
||||
| 发文字号 | 年份全称、六角括号、序号不加“第”且不补零 |
|
||||
| 上行文签发人 | “签发人:”仿宋三号,姓名楷体三号;与文号同区编排 |
|
||||
| 分隔线 | 文号下4mm,红色,与版心等宽 |
|
||||
|
||||
版头位置和红色要素可按用户提供的字段和模板进一步调整。
|
||||
|
||||
## 六、版记
|
||||
|
||||
- 版记位于公文末页,包括分隔线、抄送机关、印发机关和印发日期等。
|
||||
- 抄送机关一般用4号仿宋,左右各空一字;回行与冒号后首字对齐,末尾用句号。
|
||||
- 印发机关左空一字,印发日期右空一字,日期不补零,后加“印发”。
|
||||
- 版记分隔线有粗细区分,并与版心等宽。
|
||||
- 版记字段可按实际需要在生成后的文件中补充或调整。
|
||||
|
||||
## 七、页码
|
||||
|
||||
### 1. 正式公文标准页码
|
||||
|
||||
- 4号半角宋体阿拉伯数字。
|
||||
- 数字左右各放一条一字线。
|
||||
- 一字线上距版心下边缘7mm。
|
||||
- 单页码居右空一字,双页码居左空一字。
|
||||
- 空白页和特定版记页按标准处理;正文与附件一起装订时连续编排。
|
||||
|
||||
单双页页码与居中页码分别对应不同版式需求,生成时可显式选择。
|
||||
|
||||
### 2. 公文版式打印稿页码
|
||||
|
||||
方案、报告等材料常采用页脚居中“— 1 —”。使用 `standard` 模式时,单页右、双页左。
|
||||
|
||||
## 八、横排表格和特定格式
|
||||
|
||||
- A4横排表格的页码位置与其他页一致;单双页表头方向按标准处理。
|
||||
- 信函、命令(令)、纪要均有专门格式,不能套用普通公文首页。
|
||||
- 信函首页不显示页码,版记规则也不同。
|
||||
- 特定格式必须使用对应模板或专用生成逻辑,不得以普通报告模板代替。
|
||||
|
||||
## 九、技能能力边界
|
||||
|
||||
| 能力 | 自动生成 | 必须人工或模板处理 |
|
||||
|---|---|---|
|
||||
| A4、版心、字体、行距、正文缩进 | 是 | 打印设备误差复核 |
|
||||
| 四级正文标题 | 是 | 特殊机关模板调整 |
|
||||
| 报告居中页码 | 是 | 无 |
|
||||
| 正式单双页页码 | 是 | `--page-number standard` |
|
||||
| 普通表格 | 是 | 横排表格方向和复杂跨页表格复核 |
|
||||
| 红色机构名称、文号 | 是 | `--org`、`--doc-no` |
|
||||
| 印章、签名章 | 文字版式之外 | 在 Word/WPS 中插入图片或实体盖章 |
|
||||
| 版记 | 可编辑 | 按实际字段补充 |
|
||||
| 信函、命令、纪要 | 可编辑 | 按实际版式继续调整 |
|
||||
|
||||
## 十、建议检查
|
||||
|
||||
1. 核对文种、行文关系、主送、文号、签发人、印章和版记等需要使用的字段。
|
||||
3. 检查A4、版心、正文字体字号、22行28字的版面结果及四级标题映射;固定28pt仅作实现参数复核。
|
||||
4. 检查每个自然段及各级标题左空二字、回行顶格。
|
||||
5. 检查页码模式:打印稿居中;正式公文单页右、双页左。
|
||||
6. 检查首页是否显示正文,标题和主送机关是否留距正确。
|
||||
7. 检查附件说明、附件另页、附件编号和连续页码。
|
||||
8. 检查表格是否超版心,跨页是否断裂,横排方向是否正确。
|
||||
9. 执行DOCX结构验证、PDF转换和逐页可视检查。
|
||||
10. 需要逐项复核时,使用 `formal-checklist.md` 记录结果。
|
||||
Executable
+534
@@ -0,0 +1,534 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
const PAGE_W = 11906;
|
||||
const PAGE_H = 16838;
|
||||
const MARGIN = { top: 2098, bottom: 1984, left: 1588, right: 1474 };
|
||||
const CONTENT_W = PAGE_W - MARGIN.left - MARGIN.right;
|
||||
const FIRST_LINE_INDENT = 640;
|
||||
|
||||
function installedFontFamilies() {
|
||||
try {
|
||||
return new Set(
|
||||
execFileSync("fc-list", ["-f", "%{family}\n"], { encoding: "utf8" })
|
||||
.split(/\r?\n/)
|
||||
.flatMap((line) => line.split(","))
|
||||
.map((name) => name.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
const INSTALLED_FONTS = installedFontFamilies();
|
||||
function preferredFont(candidates) {
|
||||
return candidates.find((font) => INSTALLED_FONTS.has(font)) ?? candidates[0];
|
||||
}
|
||||
|
||||
const FONT = {
|
||||
title: { eastAsia: preferredFont(["方正小标宋简体", "方正小标宋_GBK", "FZXiaoBiaoSong-B05S"]), ascii: "Times New Roman", cs: "Times New Roman" },
|
||||
subtitle: { eastAsia: preferredFont(["仿宋", "仿宋_GB2312", "STFangsong"]), ascii: "Times New Roman", cs: "Times New Roman" },
|
||||
body: { eastAsia: preferredFont(["仿宋", "仿宋_GB2312", "STFangsong"]), ascii: "Times New Roman", cs: "Times New Roman" },
|
||||
h1: { eastAsia: preferredFont(["黑体", "SimHei", "Heiti SC", "STHeiti"]), ascii: "Times New Roman", cs: "Times New Roman" },
|
||||
h2: { eastAsia: preferredFont(["楷体", "楷体_GB2312", "KaiTi", "Kaiti SC", "STKaiti"]), ascii: "Times New Roman", cs: "Times New Roman" },
|
||||
song: { eastAsia: preferredFont(["宋体", "SimSun"]), ascii: "宋体", cs: "宋体" },
|
||||
};
|
||||
|
||||
function usage() {
|
||||
console.log(`Usage:
|
||||
node generate_gongwen_docx.mjs --input source.md --output out.docx [--org 发文机关] [--doc-no 发文字号] [--title 标题] [--subtitle 副标题] [--to 主送机关] [--sender 落款] [--date 日期] [--page-number center|standard|none]
|
||||
|
||||
Notes:
|
||||
- Converts Markdown to a GB/T 9704-2012 page-layout DOCX.
|
||||
- Generates a DOCX from the supplied content and formatting fields. Its default page number is centered; use standard for odd/even page-number placement or none to suppress it.
|
||||
- --org and --doc-no are printed exactly as supplied. The tool formats document content; it does not infer missing information or decide the document's use.
|
||||
- Supports headings, paragraphs, ordered/unordered lines, and pipe tables.
|
||||
- No npm dependencies; requires zip in PATH.`);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {};
|
||||
const flags = new Set(["help", "no-page-number", "no-page-numbers"]);
|
||||
for (let i = 2; i < argv.length; i += 1) {
|
||||
const key = argv[i];
|
||||
if (!key.startsWith("--")) continue;
|
||||
const name = key.slice(2);
|
||||
if (flags.has(name)) {
|
||||
args[name] = true;
|
||||
continue;
|
||||
}
|
||||
args[name] = argv[i + 1];
|
||||
i += 1;
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function esc(value) {
|
||||
return String(value ?? "")
|
||||
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
||||
.replace(/`([^`]+)`/g, "$1")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function attr(value) {
|
||||
return String(value ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function fontAttrs(opts = {}) {
|
||||
const preset = opts.fontPreset ? FONT[opts.fontPreset] : null;
|
||||
const eastAsia = opts.eastAsia ?? opts.font ?? preset?.eastAsia ?? FONT.body.eastAsia;
|
||||
const ascii = opts.ascii ?? preset?.ascii ?? FONT.body.ascii;
|
||||
const hAnsi = opts.hAnsi ?? ascii;
|
||||
const cs = opts.cs ?? preset?.cs ?? ascii;
|
||||
return `w:ascii="${attr(ascii)}" w:hAnsi="${attr(hAnsi)}" w:eastAsia="${attr(eastAsia)}" w:cs="${attr(cs)}" w:hint="eastAsia"`;
|
||||
}
|
||||
|
||||
function run(text, opts = {}) {
|
||||
const size = opts.size ?? "32";
|
||||
const bold = opts.bold ? "<w:b/>" : "";
|
||||
const color = opts.color ?? "000000";
|
||||
return `<w:r><w:rPr><w:rFonts ${fontAttrs(opts)}/>${bold}<w:color w:val="${color}"/><w:sz w:val="${size}"/><w:szCs w:val="${size}"/><w:lang w:val="en-US" w:eastAsia="zh-CN"/></w:rPr><w:t xml:space="preserve">${esc(text)}</w:t></w:r>`;
|
||||
}
|
||||
|
||||
function paragraph(text, opts = {}) {
|
||||
const alignValue = opts.align === undefined ? "both" : opts.align;
|
||||
const align = alignValue ? `<w:jc w:val="${alignValue}"/>` : "";
|
||||
const indAttrs = [];
|
||||
if (opts.indent !== false) indAttrs.push(`w:firstLine="${opts.firstLine ?? FIRST_LINE_INDENT}"`);
|
||||
if (opts.left) indAttrs.push(`w:left="${opts.left}"`);
|
||||
if (opts.right) indAttrs.push(`w:right="${opts.right}"`);
|
||||
const indent = indAttrs.length ? `<w:ind ${indAttrs.join(" ")}/>` : "";
|
||||
const keepNext = opts.keepNext ? "<w:keepNext/>" : "";
|
||||
const before = opts.before ?? "0";
|
||||
const after = opts.after ?? "0";
|
||||
const pPr = `<w:pPr>${keepNext}${align}${indent}<w:spacing w:before="${before}" w:after="${after}" w:line="560" w:lineRule="exact"/><w:adjustRightInd w:val="true"/><w:snapToGrid w:val="true"/><w:kinsoku w:val="true"/></w:pPr>`;
|
||||
return `<w:p>${pPr}${run(text, opts)}</w:p>`;
|
||||
}
|
||||
|
||||
function titleParagraph(text) {
|
||||
return paragraph(text, {
|
||||
align: "center",
|
||||
indent: false,
|
||||
fontPreset: "title",
|
||||
size: "44",
|
||||
before: "0",
|
||||
after: "560",
|
||||
});
|
||||
}
|
||||
|
||||
function agencyMarkParagraph(text) {
|
||||
return paragraph(text, {
|
||||
align: "center",
|
||||
indent: false,
|
||||
fontPreset: "title",
|
||||
size: "56",
|
||||
color: "FF0000",
|
||||
after: "280",
|
||||
});
|
||||
}
|
||||
|
||||
function documentNumberParagraph(text) {
|
||||
return paragraph(text, {
|
||||
align: "left",
|
||||
indent: false,
|
||||
fontPreset: "body",
|
||||
size: "32",
|
||||
after: "160",
|
||||
});
|
||||
}
|
||||
|
||||
function subtitleParagraph(text) {
|
||||
return paragraph(text, {
|
||||
align: "center",
|
||||
indent: false,
|
||||
fontPreset: "subtitle",
|
||||
size: "32",
|
||||
after: "280",
|
||||
});
|
||||
}
|
||||
|
||||
function mainSendParagraph(text) {
|
||||
return paragraph(text, {
|
||||
align: "left",
|
||||
indent: false,
|
||||
fontPreset: "body",
|
||||
size: "32",
|
||||
});
|
||||
}
|
||||
|
||||
function h1(text) {
|
||||
return paragraph(text, {
|
||||
align: "left",
|
||||
fontPreset: "h1",
|
||||
size: "32",
|
||||
keepNext: true,
|
||||
before: "160",
|
||||
after: "0",
|
||||
});
|
||||
}
|
||||
|
||||
function h2(text) {
|
||||
return paragraph(text, {
|
||||
align: "left",
|
||||
fontPreset: "h2",
|
||||
size: "32",
|
||||
keepNext: true,
|
||||
before: "80",
|
||||
after: "0",
|
||||
});
|
||||
}
|
||||
|
||||
function h3(text) {
|
||||
return paragraph(text, {
|
||||
align: "left",
|
||||
fontPreset: "body",
|
||||
size: "32",
|
||||
keepNext: true,
|
||||
before: "40",
|
||||
after: "0",
|
||||
});
|
||||
}
|
||||
|
||||
function h4(text) {
|
||||
return paragraph(text, {
|
||||
align: "left",
|
||||
fontPreset: "body",
|
||||
size: "32",
|
||||
keepNext: true,
|
||||
before: "0",
|
||||
after: "0",
|
||||
});
|
||||
}
|
||||
|
||||
function right(text) {
|
||||
return paragraph(text, { align: "right", indent: false, right: FIRST_LINE_INDENT });
|
||||
}
|
||||
|
||||
function emptyLine() {
|
||||
return `<w:p><w:pPr><w:spacing w:line="560" w:lineRule="exact"/></w:pPr></w:p>`;
|
||||
}
|
||||
|
||||
function tableXml(headers, rows) {
|
||||
const colCount = headers.length;
|
||||
const colWidth = Math.floor(CONTENT_W / colCount);
|
||||
const grid = Array.from({ length: colCount }, () => `<w:gridCol w:w="${colWidth}"/>`).join("");
|
||||
const tr = (cells, header = false) => {
|
||||
const trPr = header ? "<w:trPr><w:tblHeader/></w:trPr>" : "";
|
||||
return `<w:tr>${trPr}${cells.map((cell) => tableCell(cell, colWidth, header)).join("")}</w:tr>`;
|
||||
};
|
||||
return `<w:tbl><w:tblPr><w:tblW w:w="${CONTENT_W}" w:type="dxa"/><w:tblBorders><w:top w:val="single" w:sz="4" w:space="0" w:color="000000"/><w:left w:val="single" w:sz="4" w:space="0" w:color="000000"/><w:bottom w:val="single" w:sz="4" w:space="0" w:color="000000"/><w:right w:val="single" w:sz="4" w:space="0" w:color="000000"/><w:insideH w:val="single" w:sz="4" w:space="0" w:color="000000"/><w:insideV w:val="single" w:sz="4" w:space="0" w:color="000000"/></w:tblBorders><w:tblLook w:firstRow="1" w:lastRow="0" w:firstColumn="0" w:lastColumn="0" w:noHBand="0" w:noVBand="1"/></w:tblPr><w:tblGrid>${grid}</w:tblGrid>${tr(headers, true)}${rows.map((row) => tr(row)).join("")}</w:tbl>`;
|
||||
}
|
||||
|
||||
function tableCell(text, width, header) {
|
||||
const shade = header ? '<w:shd w:val="clear" w:color="auto" w:fill="EDEDED"/>' : "";
|
||||
const props = `<w:tcPr><w:tcW w:w="${width}" w:type="dxa"/>${shade}<w:tcMar><w:top w:w="80" w:type="dxa"/><w:left w:w="80" w:type="dxa"/><w:bottom w:w="80" w:type="dxa"/><w:right w:w="80" w:type="dxa"/></w:tcMar><w:vAlign w:val="center"/></w:tcPr>`;
|
||||
const pPr = '<w:pPr><w:spacing w:before="0" w:after="0" w:line="560" w:lineRule="exact"/><w:jc w:val="left"/></w:pPr>';
|
||||
const r = run(
|
||||
text,
|
||||
header
|
||||
? { fontPreset: "h1", size: "32" }
|
||||
: { size: "32" },
|
||||
);
|
||||
return `<w:tc>${props}<w:p>${pPr}${r}</w:p></w:tc>`;
|
||||
}
|
||||
|
||||
function stripFrontmatter(md) {
|
||||
return md.replace(/^---\n[\s\S]*?\n---\n?/, "");
|
||||
}
|
||||
|
||||
function parseMarkdown(md) {
|
||||
const lines = stripFrontmatter(md).split(/\r?\n/);
|
||||
const blocks = [];
|
||||
let para = [];
|
||||
let i = 0;
|
||||
const flush = () => {
|
||||
const text = para.join("").trim();
|
||||
if (text) blocks.push({ type: "paragraph", text });
|
||||
para = [];
|
||||
};
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
flush();
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (/^\|.+\|$/.test(trimmed)) {
|
||||
flush();
|
||||
const tableLines = [];
|
||||
while (i < lines.length && /^\|.+\|$/.test(lines[i].trim())) {
|
||||
tableLines.push(lines[i].trim());
|
||||
i += 1;
|
||||
}
|
||||
const rows = tableLines
|
||||
.filter((l) => !/^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?$/.test(l))
|
||||
.map((l) => l.replace(/^\||\|$/g, "").split("|").map((c) => c.trim()));
|
||||
if (rows.length > 0) blocks.push({ type: "table", headers: rows[0], rows: rows.slice(1) });
|
||||
continue;
|
||||
}
|
||||
const heading = trimmed.match(/^(#{1,6})\s+(.+)$/);
|
||||
if (heading) {
|
||||
flush();
|
||||
blocks.push({ type: "heading", level: heading[1].length, text: heading[2].trim() });
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
const bullet = trimmed.match(/^[-*]\s+(.+)$/);
|
||||
if (bullet) {
|
||||
flush();
|
||||
blocks.push({ type: "paragraph", text: bullet[1].trim() });
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
const ordered = trimmed.match(/^(\d+[..、])\s*(.+)$/);
|
||||
if (ordered) {
|
||||
flush();
|
||||
blocks.push({ type: "paragraph", text: `${ordered[1]} ${ordered[2].trim()}` });
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
para.push(trimmed);
|
||||
i += 1;
|
||||
}
|
||||
flush();
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function normalizeSignatureText(text) {
|
||||
return String(text ?? "").replace(/\s+/g, "");
|
||||
}
|
||||
|
||||
function isMatchingParagraph(block, text) {
|
||||
return (
|
||||
block?.type === "paragraph" &&
|
||||
normalizeSignatureText(block.text) === normalizeSignatureText(text)
|
||||
);
|
||||
}
|
||||
|
||||
function isChineseDateParagraph(block) {
|
||||
return (
|
||||
block?.type === "paragraph" &&
|
||||
/^\d{4}年\d{1,2}月\d{1,2}日$/.test(normalizeSignatureText(block.text))
|
||||
);
|
||||
}
|
||||
|
||||
function stripTrailingMatchingSignature(blocks, args) {
|
||||
if (args.date && (isMatchingParagraph(blocks.at(-1), args.date) || isChineseDateParagraph(blocks.at(-1)))) {
|
||||
blocks.pop();
|
||||
}
|
||||
if (args.sender && isMatchingParagraph(blocks.at(-1), args.sender)) {
|
||||
blocks.pop();
|
||||
}
|
||||
}
|
||||
|
||||
function isFirstLayerHeadingText(text) {
|
||||
return /^[一二三四五六七八九十百]+、/.test(String(text ?? "").trim());
|
||||
}
|
||||
|
||||
function isSecondLayerHeadingText(text) {
|
||||
return /^([一二三四五六七八九十百]+)/.test(String(text ?? "").trim());
|
||||
}
|
||||
|
||||
function isThirdLayerHeadingText(text) {
|
||||
return /^\d+[..]/.test(String(text ?? "").trim());
|
||||
}
|
||||
|
||||
function isFourthLayerHeadingText(text) {
|
||||
return /^(\d+)/.test(String(text ?? "").trim());
|
||||
}
|
||||
|
||||
function looksLikeMainSend(text) {
|
||||
const compact = String(text ?? "").replace(/\s+/g, "");
|
||||
if (!/[::]$/.test(compact) || compact.length > 60) return false;
|
||||
if (/[。!?;;]/.test(compact)) return false;
|
||||
return /(各|贵|省|市|县|区|镇|乡|机关|单位|公司|部门|委员会|办公室|厅|局|处|中心|领导|集团|党委|党总支|党支部)/.test(compact.slice(0, -1));
|
||||
}
|
||||
|
||||
function renderHeading(block) {
|
||||
if (isFirstLayerHeadingText(block.text)) return h1(block.text);
|
||||
if (isSecondLayerHeadingText(block.text)) return h2(block.text);
|
||||
if (isThirdLayerHeadingText(block.text)) return h3(block.text);
|
||||
if (isFourthLayerHeadingText(block.text)) return h4(block.text);
|
||||
if (block.level <= 2) return h1(block.text);
|
||||
if (block.level === 3) return h2(block.text);
|
||||
if (block.level === 4) return h3(block.text);
|
||||
return h4(block.text);
|
||||
}
|
||||
|
||||
function renderParagraph(block) {
|
||||
if (isFirstLayerHeadingText(block.text)) return h1(block.text);
|
||||
if (isSecondLayerHeadingText(block.text)) return h2(block.text);
|
||||
return paragraph(block.text);
|
||||
}
|
||||
|
||||
function buildDocument(blocks, args) {
|
||||
let title = args.title;
|
||||
const pageNumberMode = args["no-page-number"] || args["no-page-numbers"]
|
||||
? "none"
|
||||
: ["center", "standard", "none"].includes(args["page-number"])
|
||||
? args["page-number"]
|
||||
: "center";
|
||||
const body = [];
|
||||
const sourceBlocks = [...blocks];
|
||||
if (!title && sourceBlocks[0]?.type === "heading") {
|
||||
title = sourceBlocks.shift().text;
|
||||
}
|
||||
if (title && sourceBlocks[0]?.type === "heading" && sourceBlocks[0].text.trim() === title.trim()) {
|
||||
sourceBlocks.shift();
|
||||
}
|
||||
stripTrailingMatchingSignature(sourceBlocks, args);
|
||||
let mainSend = args.to;
|
||||
if (mainSend && isMatchingParagraph(sourceBlocks[0], mainSend)) sourceBlocks.shift();
|
||||
if (!mainSend && sourceBlocks[0]?.type === "paragraph" && looksLikeMainSend(sourceBlocks[0].text)) {
|
||||
mainSend = sourceBlocks.shift().text;
|
||||
}
|
||||
if (args.org) body.push(agencyMarkParagraph(args.org));
|
||||
if (args["doc-no"]) body.push(documentNumberParagraph(args["doc-no"]));
|
||||
if (title) body.push(titleParagraph(title));
|
||||
if (args.subtitle) body.push(subtitleParagraph(args.subtitle));
|
||||
if (mainSend) body.push(mainSendParagraph(mainSend));
|
||||
|
||||
for (const block of sourceBlocks) {
|
||||
if (block.type === "heading") {
|
||||
body.push(renderHeading(block));
|
||||
continue;
|
||||
}
|
||||
if (block.type === "table") {
|
||||
body.push(tableXml(block.headers, block.rows));
|
||||
body.push(emptyLine());
|
||||
continue;
|
||||
}
|
||||
body.push(renderParagraph(block));
|
||||
}
|
||||
if (args.sender || args.date) {
|
||||
body.push(emptyLine());
|
||||
if (args.sender) body.push(right(args.sender));
|
||||
if (args.date) body.push(right(args.date));
|
||||
}
|
||||
const footerRefs = pageNumberMode === "standard"
|
||||
? '<w:footerReference w:type="default" r:id="rIdFooterOdd"/><w:footerReference w:type="even" r:id="rIdFooterEven"/>'
|
||||
: pageNumberMode === "center"
|
||||
? '<w:footerReference w:type="default" r:id="rIdFooterCenter"/>'
|
||||
: "";
|
||||
body.push(`<w:sectPr>${footerRefs}<w:pgSz w:w="${PAGE_W}" w:h="${PAGE_H}"/><w:pgMar w:top="${MARGIN.top}" w:right="${MARGIN.right}" w:bottom="${MARGIN.bottom}" w:left="${MARGIN.left}" w:header="720" w:footer="1588" w:gutter="0"/><w:docGrid w:type="lines" w:linePitch="560"/></w:sectPr>`);
|
||||
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body>${body.join("")}</w:body></w:document>`;
|
||||
}
|
||||
|
||||
function stylesXml() {
|
||||
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:docDefaults><w:rPrDefault><w:rPr><w:rFonts ${fontAttrs({ fontPreset: "body" })}/><w:color w:val="000000"/><w:sz w:val="32"/><w:szCs w:val="32"/><w:lang w:val="en-US" w:eastAsia="zh-CN"/></w:rPr></w:rPrDefault><w:pPrDefault><w:pPr><w:spacing w:before="0" w:after="0" w:line="560" w:lineRule="exact"/><w:adjustRightInd w:val="true"/><w:snapToGrid w:val="true"/><w:kinsoku w:val="true"/></w:pPr></w:pPrDefault></w:docDefaults><w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:qFormat/><w:rPr><w:rFonts ${fontAttrs({ fontPreset: "body" })}/><w:sz w:val="32"/><w:szCs w:val="32"/><w:lang w:val="en-US" w:eastAsia="zh-CN"/></w:rPr></w:style></w:styles>`;
|
||||
}
|
||||
|
||||
function fontDef(name, altName, family = "roman", charset = "86") {
|
||||
const alt = altName ? `<w:altName w:val="${attr(altName)}"/>` : "";
|
||||
return `<w:font w:name="${attr(name)}">${alt}<w:charset w:val="${charset}"/><w:family w:val="${family}"/><w:pitch w:val="variable"/></w:font>`;
|
||||
}
|
||||
|
||||
function fontTableXml() {
|
||||
const fonts = [
|
||||
fontDef("方正小标宋简体", "Songti SC"),
|
||||
fontDef("方正小标宋_GBK", "Songti SC"),
|
||||
fontDef("FZXiaoBiaoSong-B05S", "方正小标宋简体"),
|
||||
fontDef("FZXiaoBiaoSong-B13S", "方正小标宋_GBK"),
|
||||
fontDef("华文中宋", "STZhongsong"),
|
||||
fontDef("STZhongsong", "华文中宋"),
|
||||
fontDef("仿宋", "STFangsong"),
|
||||
fontDef("仿宋_GB2312", "STFangsong"),
|
||||
fontDef("FangSong", "仿宋"),
|
||||
fontDef("FangSong_GB2312", "仿宋_GB2312"),
|
||||
fontDef("STFangsong", "仿宋"),
|
||||
fontDef("黑体", "Heiti SC", "swiss"),
|
||||
fontDef("SimHei", "黑体", "swiss"),
|
||||
fontDef("Heiti SC", "黑体", "swiss"),
|
||||
fontDef("楷体", "Kaiti SC"),
|
||||
fontDef("楷体_GB2312", "Kaiti SC"),
|
||||
fontDef("KaiTi", "楷体"),
|
||||
fontDef("KaiTi_GB2312", "楷体_GB2312"),
|
||||
fontDef("Kaiti SC", "楷体"),
|
||||
fontDef("宋体", "Songti SC"),
|
||||
fontDef("SimSun", "宋体"),
|
||||
fontDef("Songti SC", "宋体"),
|
||||
fontDef("Times New Roman", "", "roman", "00"),
|
||||
].join("");
|
||||
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:fonts xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">${fonts}</w:fonts>`;
|
||||
}
|
||||
|
||||
function footerXml(align = "center") {
|
||||
const pageRunProps = `<w:rPr><w:rFonts ${fontAttrs({ fontPreset: "song" })}/><w:sz w:val="28"/><w:szCs w:val="28"/><w:lang w:val="en-US" w:eastAsia="zh-CN"/></w:rPr>`;
|
||||
const sideIndent = align === "right"
|
||||
? '<w:ind w:right="280"/>'
|
||||
: align === "left"
|
||||
? '<w:ind w:left="280"/>'
|
||||
: "";
|
||||
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:ftr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:p><w:pPr><w:jc w:val="${align}"/>${sideIndent}<w:spacing w:before="0" w:after="0"/></w:pPr><w:r>${pageRunProps}<w:t xml:space="preserve">— </w:t></w:r><w:fldSimple w:instr="PAGE"><w:r>${pageRunProps}<w:t>1</w:t></w:r></w:fldSimple><w:r>${pageRunProps}<w:t xml:space="preserve"> —</w:t></w:r></w:p></w:ftr>`;
|
||||
}
|
||||
|
||||
function settingsXml(pageNumberMode) {
|
||||
const evenOdd = pageNumberMode === "standard" ? "<w:evenAndOddHeaders/>" : "";
|
||||
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">${evenOdd}<w:updateFields w:val="true"/></w:settings>`;
|
||||
}
|
||||
|
||||
function documentRelsXml(pageNumberMode) {
|
||||
const footerRels = pageNumberMode === "standard"
|
||||
? '<Relationship Id="rIdFooterOdd" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Target="footerOdd.xml"/><Relationship Id="rIdFooterEven" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Target="footerEven.xml"/>'
|
||||
: pageNumberMode === "center"
|
||||
? '<Relationship Id="rIdFooterCenter" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Target="footerCenter.xml"/>'
|
||||
: "";
|
||||
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdStyles" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/><Relationship Id="rIdFontTable" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/fontTable" Target="fontTable.xml"/><Relationship Id="rIdSettings" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings" Target="settings.xml"/>${footerRels}</Relationships>`;
|
||||
}
|
||||
|
||||
function writeDocx(output, documentXml, pageNumberMode) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "gongwen-docx-"));
|
||||
fs.mkdirSync(path.join(dir, "_rels"), { recursive: true });
|
||||
fs.mkdirSync(path.join(dir, "word"), { recursive: true });
|
||||
fs.mkdirSync(path.join(dir, "word/_rels"), { recursive: true });
|
||||
const footerTypes = pageNumberMode === "standard"
|
||||
? '<Override PartName="/word/footerOdd.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"/><Override PartName="/word/footerEven.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"/>'
|
||||
: pageNumberMode === "center"
|
||||
? '<Override PartName="/word/footerCenter.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"/>'
|
||||
: "";
|
||||
fs.writeFileSync(path.join(dir, "[Content_Types].xml"), `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/><Override PartName="/word/fontTable.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.fontTable+xml"/><Override PartName="/word/settings.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml"/>${footerTypes}</Types>`);
|
||||
fs.writeFileSync(path.join(dir, "_rels/.rels"), `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>`);
|
||||
fs.writeFileSync(path.join(dir, "word/_rels/document.xml.rels"), documentRelsXml(pageNumberMode));
|
||||
fs.writeFileSync(path.join(dir, "word/document.xml"), documentXml);
|
||||
fs.writeFileSync(path.join(dir, "word/styles.xml"), stylesXml());
|
||||
fs.writeFileSync(path.join(dir, "word/fontTable.xml"), fontTableXml());
|
||||
fs.writeFileSync(path.join(dir, "word/settings.xml"), settingsXml(pageNumberMode));
|
||||
if (pageNumberMode === "standard") {
|
||||
fs.writeFileSync(path.join(dir, "word/footerOdd.xml"), footerXml("right"));
|
||||
fs.writeFileSync(path.join(dir, "word/footerEven.xml"), footerXml("left"));
|
||||
} else if (pageNumberMode === "center") {
|
||||
fs.writeFileSync(path.join(dir, "word/footerCenter.xml"), footerXml("center"));
|
||||
}
|
||||
fs.rmSync(output, { force: true });
|
||||
execFileSync("zip", ["-qr", output, "."], { cwd: dir });
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const args = parseArgs(process.argv);
|
||||
if (args.help || !args.input || !args.output) {
|
||||
usage();
|
||||
process.exit(args.help ? 0 : 1);
|
||||
}
|
||||
const md = fs.readFileSync(args.input, "utf8");
|
||||
const blocks = parseMarkdown(md);
|
||||
const documentXml = buildDocument(blocks, args);
|
||||
const pageNumberMode = args["no-page-number"] || args["no-page-numbers"]
|
||||
? "none"
|
||||
: ["center", "standard", "none"].includes(args["page-number"])
|
||||
? args["page-number"]
|
||||
: "center";
|
||||
writeDocx(path.resolve(args.output), documentXml, pageNumberMode);
|
||||
console.log(`Generated: ${path.resolve(args.output)}`);
|
||||
@@ -0,0 +1,41 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidateSet('codex', 'claude', 'opencode', 'trae-code', 'trae-cli', 'kimi-cli', 'kimi-code', 'workbuddy', 'zcode')]
|
||||
[string[]]$Target,
|
||||
[switch]$All,
|
||||
[switch]$Link,
|
||||
[switch]$Force
|
||||
)
|
||||
|
||||
$SkillDir = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
||||
if (-not $Target -and -not $All) { throw 'Pass -All or at least one -Target.' }
|
||||
if ($All) { $Target = @('codex', 'claude', 'opencode', 'trae-code', 'trae-cli', 'kimi-cli', 'kimi-code', 'workbuddy', 'zcode') }
|
||||
|
||||
$Roots = @{
|
||||
'codex' = "$HOME\.codex\skills"
|
||||
'claude' = "$HOME\.claude\skills"
|
||||
'opencode' = "$HOME\.config\opencode\skills"
|
||||
'trae-code' = "$HOME\.trae-cn\skills"
|
||||
'trae-cli' = "$HOME\.traecli\skills"
|
||||
'kimi-cli' = "$HOME\.kimi\skills"
|
||||
'kimi-code' = "$HOME\.kimi-code\skills"
|
||||
'workbuddy' = "$HOME\.workbuddy\skills"
|
||||
'zcode' = "$HOME\.zcode\skills"
|
||||
}
|
||||
|
||||
foreach ($Name in $Target) {
|
||||
$Root = $Roots[$Name]
|
||||
$Destination = Join-Path $Root 'gongwen'
|
||||
New-Item -ItemType Directory -Force -Path $Root | Out-Null
|
||||
if (Test-Path $Destination) {
|
||||
if (-not $Force) { throw "Refusing to replace $Destination. Review it, then rerun with -Force." }
|
||||
Remove-Item -Recurse -Force $Destination
|
||||
}
|
||||
if ($Link) {
|
||||
New-Item -ItemType SymbolicLink -Path $Destination -Target $SkillDir | Out-Null
|
||||
} else {
|
||||
Copy-Item -Recurse -Force -Exclude '.git', 'dist' -Path $SkillDir -Destination $Destination
|
||||
}
|
||||
if (-not (Test-Path (Join-Path $Destination 'SKILL.md'))) { throw "Installation failed for $Name." }
|
||||
Write-Output "Installed: $Name -> $Destination"
|
||||
}
|
||||
Executable
+91
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
skill_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
force=false
|
||||
targets=()
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: scripts/install.sh [--all | --target NAME ...] [--force]
|
||||
|
||||
Targets: codex, claude, opencode, trae-code, trae-cli, kimi-cli, kimi-code,
|
||||
workbuddy, zcode, traework
|
||||
|
||||
Installs the canonical skill as a symbolic link. Existing non-linked gongwen
|
||||
directories are preserved unless --force is supplied. The traework target
|
||||
creates a ZIP for import in TraeWork instead of guessing an unsupported path.
|
||||
EOF
|
||||
}
|
||||
|
||||
while (($#)); do
|
||||
case "$1" in
|
||||
--all) targets=(codex claude opencode trae-code trae-cli kimi-cli kimi-code workbuddy zcode traework) ;;
|
||||
--target) shift; targets+=("${1:?missing target name}") ;;
|
||||
--force) force=true ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) printf 'Unknown option: %s\n' "$1" >&2; usage >&2; exit 2 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
if ((${#targets[@]} == 0)); then
|
||||
usage >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
target_root() {
|
||||
case "$1" in
|
||||
codex) printf '%s/.codex/skills' "$HOME" ;;
|
||||
claude) printf '%s/.claude/skills' "$HOME" ;;
|
||||
opencode) printf '%s/.config/opencode/skills' "$HOME" ;;
|
||||
trae-code) printf '%s/.trae-cn/skills' "$HOME" ;;
|
||||
trae-cli) printf '%s/.traecli/skills' "$HOME" ;;
|
||||
kimi-cli) printf '%s/.kimi/skills' "$HOME" ;;
|
||||
kimi-code) printf '%s/.kimi-code/skills' "$HOME" ;;
|
||||
workbuddy) printf '%s/.workbuddy/skills' "$HOME" ;;
|
||||
zcode) printf '%s/.zcode/skills' "$HOME" ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
install_link() {
|
||||
local name=$1 root destination
|
||||
root=$(target_root "$name") || { printf 'Unsupported target: %s\n' "$name" >&2; exit 2; }
|
||||
destination="$root/gongwen"
|
||||
mkdir -p "$root"
|
||||
if [[ -e "$destination" || -L "$destination" ]]; then
|
||||
if [[ -L "$destination" && $(readlink "$destination") == "$skill_dir" ]]; then
|
||||
printf 'Already installed: %s\n' "$name"
|
||||
return
|
||||
fi
|
||||
if [[ "$force" != true ]]; then
|
||||
printf 'Refusing to replace existing %s; rerun with --force after review.\n' "$destination" >&2
|
||||
exit 3
|
||||
fi
|
||||
rm -rf "$destination"
|
||||
fi
|
||||
ln -s "$skill_dir" "$destination"
|
||||
test -f "$destination/SKILL.md"
|
||||
printf 'Installed: %s -> %s\n' "$name" "$destination"
|
||||
}
|
||||
|
||||
package_traework() {
|
||||
local stage output
|
||||
stage=$(mktemp -d "${TMPDIR:-/tmp}/gongwen-traework.XXXXXX")
|
||||
output="$skill_dir/dist/traework-gongwen-skill.zip"
|
||||
mkdir -p "$skill_dir/dist" "$stage/gongwen"
|
||||
cp -R "$skill_dir/SKILL.md" "$skill_dir/scripts" "$skill_dir/references" "$stage/gongwen/"
|
||||
(cd "$stage" && zip -qr "$output" gongwen)
|
||||
unzip -t "$output" >/dev/null
|
||||
rm -rf "$stage"
|
||||
printf 'Packaged TraeWork import ZIP: %s\n' "$output"
|
||||
}
|
||||
|
||||
for target in "${targets[@]}"; do
|
||||
if [[ "$target" == traework ]]; then
|
||||
package_traework
|
||||
else
|
||||
install_link "$target"
|
||||
fi
|
||||
done
|
||||
Executable
+91
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
|
||||
function usage() {
|
||||
console.log("Usage: node verify_gongwen_docx.mjs --input file.docx [--profile layout|standard-pages]");
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {};
|
||||
for (let i = 2; i < argv.length; i += 1) {
|
||||
if (!argv[i].startsWith("--")) continue;
|
||||
args[argv[i].slice(2)] = argv[i + 1];
|
||||
i += 1;
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function readPart(file, part, optional = false) {
|
||||
try {
|
||||
return execFileSync("unzip", ["-p", file, part], { encoding: "utf8" });
|
||||
} catch (error) {
|
||||
if (optional) return "";
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function paragraphsForText(xml, pattern) {
|
||||
const paragraphs = xml.match(/<w:p(?:\s[^>]*)?>[\s\S]*?<\/w:p>/g) ?? [];
|
||||
return paragraphs.filter((paragraph) => pattern.test(paragraph.replace(/<[^>]+>/g, "")));
|
||||
}
|
||||
|
||||
const args = parseArgs(process.argv);
|
||||
if (!args.input || !fs.existsSync(args.input)) {
|
||||
usage();
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const profile = args.profile ?? "layout";
|
||||
if (!["layout", "standard-pages"].includes(profile)) {
|
||||
console.error("Unsupported profile. Use layout or standard-pages.");
|
||||
process.exit(2);
|
||||
}
|
||||
const doc = readPart(args.input, "word/document.xml");
|
||||
const styles = readPart(args.input, "word/styles.xml");
|
||||
const h1 = paragraphsForText(doc, /^[一二三四五六七八九十]+、/);
|
||||
const h2 = paragraphsForText(doc, /^([一二三四五六七八九十]+)/);
|
||||
const h3 = paragraphsForText(doc, /(?:^|\s)\d+[..]/);
|
||||
const h4 = paragraphsForText(doc, /(\d+)/);
|
||||
const checks = [];
|
||||
|
||||
function check(name, ok, detail) {
|
||||
checks.push({ name, ok, detail });
|
||||
}
|
||||
|
||||
check("A4 page size", /<w:pgSz[^>]*w:w="11906"[^>]*w:h="16838"/.test(doc), "210 mm x 297 mm");
|
||||
check("Page margins", /<w:pgMar[^>]*w:top="2098"[^>]*w:right="1474"[^>]*w:bottom="1984"[^>]*w:left="1588"/.test(doc), "top 37, bottom 35, left 28, right 26 mm");
|
||||
check("Document grid", /<w:docGrid[^>]*w:linePitch="560"/.test(doc), "28 pt grid");
|
||||
const fangSong = /w:eastAsia="(?:仿宋|仿宋_GB2312|FangSong|FangSong_GB2312|STFangsong)"/;
|
||||
const xiaoBiaoSong = /w:eastAsia="(?:方正小标宋简体|方正小标宋_GBK|FZXiaoBiaoSong-B05S)"/;
|
||||
const heiTi = /w:eastAsia="(?:黑体|SimHei|Heiti SC|STHeiti)"/;
|
||||
const kaiTi = /w:eastAsia="(?:楷体|楷体_GB2312|KaiTi|KaiTi_GB2312|Kaiti SC|STKaiti)"/;
|
||||
check("Default body font", fangSong.test(styles) && /<w:sz w:val="32"/.test(styles), "FangSong-compatible 16 pt");
|
||||
const allMatch = (paragraphs, matcher) => paragraphs.length > 0 && paragraphs.every(matcher);
|
||||
check("Title font", xiaoBiaoSong.test(doc) && /<w:sz w:val="44"/.test(doc), "Xiaobiaosong 22 pt requested");
|
||||
check("Level-1 headings", allMatch(h1, (paragraph) => /w:firstLine="640"/.test(paragraph) && heiTi.test(paragraph) && /w:sz w:val="32"/.test(paragraph)), "every 一、 heading is Heiti-compatible 16 pt with two-character indent");
|
||||
check("Level-2 headings", allMatch(h2, (paragraph) => /w:firstLine="640"/.test(paragraph) && kaiTi.test(paragraph) && /w:sz w:val="32"/.test(paragraph)), "every (一) heading is Kaiti-compatible 16 pt with two-character indent");
|
||||
check("Level-3 headings", allMatch(h3, (paragraph) => /w:firstLine="640"/.test(paragraph) && fangSong.test(paragraph) && /w:sz w:val="32"/.test(paragraph)), "every 1. heading is FangSong-compatible 16 pt with two-character indent");
|
||||
check("Level-4 headings", allMatch(h4, (paragraph) => /w:firstLine="640"/.test(paragraph) && fangSong.test(paragraph) && /w:sz w:val="32"/.test(paragraph)), "every (1) heading is FangSong-compatible 16 pt with two-character indent");
|
||||
if (profile === "standard-pages") {
|
||||
const oddFooter = readPart(args.input, "word/footerOdd.xml", true);
|
||||
const evenFooter = readPart(args.input, "word/footerEven.xml", true);
|
||||
check("Odd-page footer", /w:jc w:val="right"/.test(oddFooter) && /w:instr="PAGE"/.test(oddFooter) && /w:ascii="(?:宋体|SimSun)"/.test(oddFooter), "right-aligned page number in Songti-compatible font");
|
||||
check("Even-page footer", /w:jc w:val="left"/.test(evenFooter) && /w:instr="PAGE"/.test(evenFooter) && /w:ascii="(?:宋体|SimSun)"/.test(evenFooter), "left-aligned page number in Songti-compatible font");
|
||||
} else {
|
||||
const footer = readPart(args.input, "word/footerCenter.xml", true);
|
||||
check("Layout page footer", /w:jc w:val="center"/.test(footer) && /w:instr="PAGE"/.test(footer) && /w:ascii="(?:宋体|SimSun)"/.test(footer), "centered page number in Songti-compatible font");
|
||||
}
|
||||
|
||||
for (const item of checks) {
|
||||
console.log(`${item.ok ? "PASS" : "FAIL"} ${item.name}: ${item.detail}`);
|
||||
}
|
||||
|
||||
const failed = checks.filter((item) => !item.ok);
|
||||
if (failed.length) {
|
||||
console.error(`Verification failed: ${failed.length} item(s).`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Verification passed (${profile} profile): ${checks.length} item(s).`);
|
||||
@@ -0,0 +1,17 @@
|
||||
# 公文格式回归测试
|
||||
|
||||
## 一、一级标题
|
||||
|
||||
这是正文自然段,用于验证仿宋三号、固定行距和首行缩进。
|
||||
|
||||
### (一)二级标题
|
||||
|
||||
这是二级标题后的正文。
|
||||
|
||||
#### 1. 三级标题
|
||||
|
||||
这是三级标题后的正文。
|
||||
|
||||
##### (1)四级标题
|
||||
|
||||
这是四级标题后的正文。
|
||||
Executable
+31
@@ -0,0 +1,31 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
SKILL_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
NODE=${NODE:-node}
|
||||
TMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/gongwen-test.XXXXXX")
|
||||
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||
|
||||
GEN="$SKILL_DIR/scripts/generate_gongwen_docx.mjs"
|
||||
VERIFY="$SKILL_DIR/scripts/verify_gongwen_docx.mjs"
|
||||
FIXTURE="$SKILL_DIR/tests/fixture.md"
|
||||
|
||||
"$NODE" --check "$GEN"
|
||||
"$NODE" --check "$VERIFY"
|
||||
|
||||
"$NODE" "$GEN" --input "$FIXTURE" --output "$TMP_DIR/layout.docx" --title "公文格式回归测试" --page-number center
|
||||
"$NODE" "$VERIFY" --input "$TMP_DIR/layout.docx" --profile layout
|
||||
unzip -t "$TMP_DIR/layout.docx" >/dev/null
|
||||
|
||||
"$NODE" "$GEN" --input "$FIXTURE" --output "$TMP_DIR/standard.docx" --org "示例单位文件" --doc-no "示例发〔2026〕1号" --title "公文格式回归测试" --page-number standard
|
||||
"$NODE" "$VERIFY" --input "$TMP_DIR/standard.docx" --profile standard-pages
|
||||
unzip -p "$TMP_DIR/standard.docx" word/document.xml | grep -q '示例单位文件'
|
||||
unzip -p "$TMP_DIR/standard.docx" word/document.xml | grep -q '示例发〔2026〕1号'
|
||||
|
||||
if command -v soffice >/dev/null 2>&1; then
|
||||
mkdir -p "$TMP_DIR/pdf"
|
||||
soffice --headless --convert-to pdf --outdir "$TMP_DIR/pdf" "$TMP_DIR/layout.docx" >/dev/null 2>&1
|
||||
test -s "$TMP_DIR/pdf/layout.pdf"
|
||||
fi
|
||||
|
||||
printf '%s\n' "gongwen regression tests passed"
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
skill_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
test_home=$(mktemp -d "${TMPDIR:-/tmp}/gongwen-install-test.XXXXXX")
|
||||
trap 'rm -rf "$test_home"' EXIT
|
||||
|
||||
HOME="$test_home" "$skill_dir/scripts/install.sh" --all
|
||||
|
||||
for path in \
|
||||
.codex/skills/gongwen \
|
||||
.claude/skills/gongwen \
|
||||
.config/opencode/skills/gongwen \
|
||||
.trae-cn/skills/gongwen \
|
||||
.traecli/skills/gongwen \
|
||||
.kimi/skills/gongwen \
|
||||
.kimi-code/skills/gongwen \
|
||||
.workbuddy/skills/gongwen \
|
||||
.zcode/skills/gongwen; do
|
||||
test -L "$test_home/$path"
|
||||
test -f "$test_home/$path/SKILL.md"
|
||||
done
|
||||
|
||||
unzip -t "$skill_dir/dist/traework-gongwen-skill.zip" >/dev/null
|
||||
unzip -l "$skill_dir/dist/traework-gongwen-skill.zip" | grep -q 'gongwen/SKILL.md'
|
||||
printf '%s\n' 'cross-platform installation tests passed'
|
||||
Reference in New Issue
Block a user