commit 2dd6729f0779b47c3d9c61ff24024ce8e865dc93 Author: mizzlelover Date: Wed Sep 9 20:19:21 2026 +0800 发布 GB/T 9704-2012 公文排版技能 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..591d414 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b3ccf87 --- /dev/null +++ b/README.md @@ -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。标准文本及机关模板应按各自权利和管理规则取得与使用。 diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..1d41784 --- /dev/null +++ b/SKILL.md @@ -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` 逐项复核。 diff --git a/dist/traework-gongwen-skill.zip b/dist/traework-gongwen-skill.zip new file mode 100644 index 0000000..3d48a1d Binary files /dev/null and b/dist/traework-gongwen-skill.zip differ diff --git a/docs/platform-support.md b/docs/platform-support.md new file mode 100644 index 0000000..bf888d8 --- /dev/null +++ b/docs/platform-support.md @@ -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.md` 作为技能目录结构;本项目同时保留用户级目录安装,便于本地技能管理器发现。 diff --git a/references/formal-checklist.md b/references/formal-checklist.md new file mode 100644 index 0000000..84ae5fe --- /dev/null +++ b/references/formal-checklist.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 复核。 diff --git a/references/gbt9704-2012-summary.md b/references/gbt9704-2012-summary.md new file mode 100644 index 0000000..37782a5 --- /dev/null +++ b/references/gbt9704-2012-summary.md @@ -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` 记录结果。 diff --git a/scripts/generate_gongwen_docx.mjs b/scripts/generate_gongwen_docx.mjs new file mode 100755 index 0000000..ec6ea83 --- /dev/null +++ b/scripts/generate_gongwen_docx.mjs @@ -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, """); +} + +function attr(value) { + return String(value ?? "") + .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 ? "" : ""; + const color = opts.color ?? "000000"; + return `${bold}${esc(text)}`; +} + +function paragraph(text, opts = {}) { + const alignValue = opts.align === undefined ? "both" : opts.align; + const align = 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 ? `` : ""; + const keepNext = opts.keepNext ? "" : ""; + const before = opts.before ?? "0"; + const after = opts.after ?? "0"; + const pPr = `${keepNext}${align}${indent}`; + return `${pPr}${run(text, opts)}`; +} + +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 ``; +} + +function tableXml(headers, rows) { + const colCount = headers.length; + const colWidth = Math.floor(CONTENT_W / colCount); + const grid = Array.from({ length: colCount }, () => ``).join(""); + const tr = (cells, header = false) => { + const trPr = header ? "" : ""; + return `${trPr}${cells.map((cell) => tableCell(cell, colWidth, header)).join("")}`; + }; + return `${grid}${tr(headers, true)}${rows.map((row) => tr(row)).join("")}`; +} + +function tableCell(text, width, header) { + const shade = header ? '' : ""; + const props = `${shade}`; + const pPr = ''; + const r = run( + text, + header + ? { fontPreset: "h1", size: "32" } + : { size: "32" }, + ); + return `${props}${pPr}${r}`; +} + +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" + ? '' + : pageNumberMode === "center" + ? '' + : ""; + body.push(`${footerRefs}`); + return `${body.join("")}`; +} + +function stylesXml() { + return ``; +} + +function fontDef(name, altName, family = "roman", charset = "86") { + const alt = altName ? `` : ""; + return `${alt}`; +} + +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 `${fonts}`; +} + +function footerXml(align = "center") { + const pageRunProps = ``; + const sideIndent = align === "right" + ? '' + : align === "left" + ? '' + : ""; + return `${sideIndent}${pageRunProps}— ${pageRunProps}1${pageRunProps} —`; +} + +function settingsXml(pageNumberMode) { + const evenOdd = pageNumberMode === "standard" ? "" : ""; + return `${evenOdd}`; +} + +function documentRelsXml(pageNumberMode) { + const footerRels = pageNumberMode === "standard" + ? '' + : pageNumberMode === "center" + ? '' + : ""; + return `${footerRels}`; +} + +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" + ? '' + : pageNumberMode === "center" + ? '' + : ""; + fs.writeFileSync(path.join(dir, "[Content_Types].xml"), `${footerTypes}`); + fs.writeFileSync(path.join(dir, "_rels/.rels"), ``); + 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)}`); diff --git a/scripts/install.ps1 b/scripts/install.ps1 new file mode 100644 index 0000000..6ec338d --- /dev/null +++ b/scripts/install.ps1 @@ -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" +} diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..0c6b7c5 --- /dev/null +++ b/scripts/install.sh @@ -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 diff --git a/scripts/verify_gongwen_docx.mjs b/scripts/verify_gongwen_docx.mjs new file mode 100755 index 0000000..2680a5b --- /dev/null +++ b/scripts/verify_gongwen_docx.mjs @@ -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(/]*)?>[\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:w="11906"[^>]*w:h="16838"/.test(doc), "210 mm x 297 mm"); +check("Page margins", /]*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: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) && / paragraphs.length > 0 && paragraphs.every(matcher); +check("Title font", xiaoBiaoSong.test(doc) && / /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).`); diff --git a/tests/fixture.md b/tests/fixture.md new file mode 100644 index 0000000..01b5570 --- /dev/null +++ b/tests/fixture.md @@ -0,0 +1,17 @@ +# 公文格式回归测试 + +## 一、一级标题 + +这是正文自然段,用于验证仿宋三号、固定行距和首行缩进。 + +### (一)二级标题 + +这是二级标题后的正文。 + +#### 1. 三级标题 + +这是三级标题后的正文。 + +##### (1)四级标题 + +这是四级标题后的正文。 diff --git a/tests/run_tests.sh b/tests/run_tests.sh new file mode 100755 index 0000000..e5b3657 --- /dev/null +++ b/tests/run_tests.sh @@ -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" diff --git a/tests/test-install.sh b/tests/test-install.sh new file mode 100755 index 0000000..444cc4d --- /dev/null +++ b/tests/test-install.sh @@ -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'