发布 GB/T 9704-2012 公文排版技能

This commit is contained in:
mizzlelover
2026-09-09 20:19:21 +08:00
commit 2dd6729f07
14 changed files with 1212 additions and 0 deletions
+21
View File
@@ -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.
+68
View File
@@ -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 "示例发〔20261号" --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。标准文本及机关模板应按各自权利和管理规则取得与使用。
+45
View File
@@ -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 "单位发〔20261号" --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` 逐项复核。
BIN
View File
Binary file not shown.
+47
View File
@@ -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` 作为技能目录结构;本项目同时保留用户级目录安装,便于本地技能管理器发现。
+40
View File
@@ -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 复核。
+160
View File
@@ -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 文件中按需插入。
## 三、纸张、版面和印制
| 项目 | 标准口径 | 生成参数 |
|---|---|---|
| 纸张 | A4210mm×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` 记录结果。
+534
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function attr(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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)}`);
+41
View File
@@ -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"
}
+91
View File
@@ -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
+91
View File
@@ -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).`);
+17
View File
@@ -0,0 +1,17 @@
# 公文格式回归测试
## 一、一级标题
这是正文自然段,用于验证仿宋三号、固定行距和首行缩进。
### (一)二级标题
这是二级标题后的正文。
#### 1. 三级标题
这是三级标题后的正文。
##### 1)四级标题
这是四级标题后的正文。
+31
View File
@@ -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 "示例发〔20261号" --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 '示例发〔20261号'
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"
+26
View File
@@ -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'