完善公文版式与红头套打规则
This commit is contained in:
@@ -75,21 +75,32 @@ https://std.samr.gov.cn/gb/search/gbDetailed?id=lOIe27f77QU%3D&mode=p
|
||||
| 场景 | 本项目的处理方式 |
|
||||
| --- | --- |
|
||||
| 文档主体 | A4、版心、标题、正文、层次、署名和日期排版 |
|
||||
| 机构名称与文号 | 使用 `--org`、`--doc-no` 按输入排版 |
|
||||
| 普通报告、方案、汇报 | 默认 `ordinary`,不因机构名称自动使用红头 |
|
||||
| 正式发文与红头纸 | 显式 `formal`;默认预印红头纸套打,首面留白、不重绘红色机关标志和红线 |
|
||||
| 完整电子红头 | 显式 `formal --letterhead digital`,才在 DOCX 中绘制红头要素 |
|
||||
| 机构名称与文号 | 使用 `--org`、`--doc-no` 按输入排版;文号仅在相应版式中定位 |
|
||||
| 标题与目录 | 四级标题写入 Word/WPS 标题样式及大纲级别,可插入自动目录 |
|
||||
| 页码 | 居中、单双页或不显示 |
|
||||
| 印章、签名章 | 在生成的 DOCX 中按实际需要插入图片或完成实体盖章 |
|
||||
| 特定格式 | 可在此版式基础上继续编辑,或使用对应模板 |
|
||||
| 特定格式 | `letter`、`command`、`minutes` 使用对应生成分支 |
|
||||
|
||||
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
|
||||
node scripts/generate_gongwen_docx.mjs --input tests/fixture.md --output /tmp/gongwen.docx --format ordinary --title "公文格式回归测试"
|
||||
node scripts/verify_gongwen_docx.mjs --input /tmp/gongwen.docx --profile ordinary
|
||||
```
|
||||
|
||||
然后用 Word 或 WPS 打开,并转换 PDF 检查标题、正文、表格、分页和页码。生成器支持 `center`、`standard` 和 `none` 页码模式,机构名称和文号按输入写入文档。
|
||||
需要正式发文时,必须明确选择版式。下面的命令生成预印红头纸套打稿;首页只留出纸上已有红头和红线区域,DOCX 不会重绘它们。`72` 是默认预留高度,应按本单位实际红头纸改为 `37—130` 毫米。
|
||||
|
||||
```bash
|
||||
node scripts/generate_gongwen_docx.mjs --input tests/fixture.md --output /tmp/formal.docx --format formal --letterhead preprinted --letterhead-reserve-mm 72 --org "示例单位文件" --doc-no "示例发〔2026〕1号" --title "公文格式回归测试"
|
||||
node scripts/verify_gongwen_docx.mjs --input /tmp/formal.docx --profile formal --letterhead preprinted
|
||||
```
|
||||
|
||||
只有需要完整电子红头文件时,才加 `--letterhead digital`。打开文档后,Word/WPS 的“引用→目录”可以按“标题1—标题4”生成并更新目录;是否显示第四级,在目录设置中选择。
|
||||
|
||||
## 跨平台安装
|
||||
|
||||
|
||||
@@ -13,19 +13,29 @@ GB/T 9704-2012《党政机关公文格式》现行,2025-05-30 复审继续有
|
||||
|
||||
## 工具定位
|
||||
|
||||
本工具只处理排版。它不判断用户材料的用途、单位身份、授权状态或后续盖章方式,也不推断用户未提供的要素。机构名称、文号、主送机关、署名、日期和页码模式均按用户输入排入 DOCX。
|
||||
本工具只处理排版,不判断单位身份、授权状态或后续盖章方式。它会根据用户明确选择的版式模式应用对应的排版规则,不因“正式”“国企”“报告”或仅提供机构名称而擅自使用红头。
|
||||
|
||||
生成器可输出 A4、156mm×225mm 版心、三号仿宋正文、小标宋二号标题、四级标题层次、两字缩进,以及居中或单双页页码。
|
||||
生成器可输出 A4、156mm×225mm 版心、三号仿宋正文、小标宋二号标题、四级标题层次、两字缩进,以及居中或单双页页码。四级标题均写入 Word/WPS 标题样式和大纲级别,便于后续插入、更新目录;文章总标题使用独立“公文标题”样式,不进入目录。
|
||||
|
||||
`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
|
||||
node scripts/generate_gongwen_docx.mjs --input source.md --output output.docx --format ordinary --title "文档标题"
|
||||
```
|
||||
|
||||
支持 `center`、`standard` 与 `none` 三种页码模式,默认 `center`。`standard` 使用单页右、双页左的页码位置。`--org` 以红色居中机构名称排版,`--doc-no` 在其下方排版。
|
||||
普通材料使用 `--format ordinary`,默认居中页码,不绘制红头。正式发文须显式使用 `--format formal`;其默认 `--letterhead preprinted`,即首面预留红头纸区域、不在 DOCX 中重绘纸上已有的红色机关标志和红线。`--doc-no` 等本次需打印的黑色变量仍可保留。只有明确要求完整电子版时使用 `--letterhead digital`。
|
||||
|
||||
```bash
|
||||
# 预印红头纸套打;72mm 是默认机关标志区域高度,可按实际纸样调为 37—130mm
|
||||
node scripts/generate_gongwen_docx.mjs --input source.md --output output.docx --format formal --letterhead preprinted --letterhead-reserve-mm 72 --org "发文机关名称" --doc-no "单位发〔2026〕1号" --title "文档标题" --sender "落款单位" --date "2026年9月9日"
|
||||
|
||||
# 完整电子红头版,仅在明确要求时使用
|
||||
node scripts/generate_gongwen_docx.mjs --input source.md --output output.docx --format formal --letterhead digital --org "发文机关名称" --doc-no "单位发〔2026〕1号" --title "文档标题"
|
||||
```
|
||||
|
||||
`letter`、`command`、`minutes` 分别对应信函、命令(令)、纪要格式;需要时显式传入 `--format` 和相应字段。正式发文默认单双页页码;`standard` 为单页右、双页左,`center` 与 `none` 供普通材料选择。
|
||||
|
||||
Markdown 标题优先按序数识别:`一、`、`(一)`、`1.`、`(1)`;序数缺失时才按 Markdown 层级映射。正文、四级标题均左空二字,回行顶格。标题字体在运行机器缺失时,会保留请求的字体名;打开文件时仍须确认 Office 的实际替代字体,不把普通宋体视作小标宋的等价替代。
|
||||
|
||||
@@ -34,10 +44,10 @@ Markdown 标题优先按序数识别:`一、`、`(一)`、`1.`、`(1)`
|
||||
```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
|
||||
node scripts/verify_gongwen_docx.mjs --input output.docx --profile formal --letterhead preprinted
|
||||
```
|
||||
|
||||
校验器核验生成器可检查的版式要素。`layout` 用于居中页码,`standard-pages` 用于单双页页码。输出后可在目标 Word/WPS 与实际打印条件下检查首页正文、分页、附件、表格、页码和装订。
|
||||
校验器核验生成器可检查的版式要素,并按 `ordinary`、`formal`、`letter`、`command`、`minutes` 选择校验档案。输出后仍须在目标 Word/WPS 与实际打印条件下检查首页正文、分页、附件、表格、页码、红头纸套打和装订。
|
||||
|
||||
## 输出说明
|
||||
|
||||
|
||||
Vendored
BIN
Binary file not shown.
@@ -4,6 +4,8 @@
|
||||
|
||||
## 1. 前提与制作
|
||||
|
||||
- [ ] 已明确选择 `ordinary`、`formal`、`letter`、`command` 或 `minutes`;报告、方案、汇报材料不因出现机构名称而自动采用红头。
|
||||
- [ ] 正式发文已确认采用预印红头纸套打或完整电子红头。套打稿以实际纸样核定首页预留高度,不重复打印纸上既有的红色机关标志、红线。
|
||||
- [ ] 已确认发文机关、文种、行文关系、主送范围、发文字号、签发权限及机关现行模板。
|
||||
- [ ] A4 成品幅面为 210mm×297mm;天头 37mm±1mm、订口 28mm±1mm、版心 156mm×225mm。
|
||||
- [ ] 无特殊说明时采用三号仿宋;一般每面22行、每行28字并撑满版心。不得把固定28pt当作国标唯一强制值。
|
||||
@@ -25,6 +27,7 @@
|
||||
- [ ] 主送机关在标题下空一行顶格,回行顶格,最后名称后用全角冒号;过多而影响首页正文时移入版记。
|
||||
- [ ] 首页显示正文;正文一般三号仿宋,每个自然段左空二字、回行顶格。
|
||||
- [ ] 层次序数按“一、”“(一)”“1.”“(1)”使用;第一层黑体、第二层楷体、第三和第四层仿宋,均为三号。
|
||||
- [ ] 四级标题分别应用 Word/WPS 的“标题1—标题4”样式和相应大纲级别;文档总标题不进入目录。需要目录时,已在目标软件中插入并更新自动目录。
|
||||
- [ ] 附件说明位于正文下空一行、左空二字;“附件:”后名称无标点;多附件编号正确,长名称回行与首字对齐。
|
||||
- [ ] 署名、成文日期和印章已按“加盖印章/不加盖印章/签发人签名章/联合行文”之一完整处理;成文日期年月日用阿拉伯数字全称且月日不补零;没有空白印章或伪造印章。
|
||||
- [ ] 附注适用时在成文日期下一行左空二字,以圆括号标注。
|
||||
|
||||
@@ -11,17 +11,21 @@
|
||||
- 标准状态页: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纸、标准版心、字号、行距、标题层级和页码,可按输入加入机构名称、文号、主送机关、署名和日期。
|
||||
适用于方案、报告、说明、备忘材料和内部汇报。采用A4纸、标准版心、字号、行距、标题层级和页码。出现机构名称不等于采用红头;普通材料不使用版头专属规则。
|
||||
|
||||
### 2. 正式党政机关公文
|
||||
|
||||
除页面和正文格式外,还可按实际需要处理版头、主体、版记、页码、印章或签名章。生成器排版用户提供的文字字段;印章和签名章可在生成后的 Word/WPS 文件中按需插入。
|
||||
在用户明确要求正式发文版式、并提供相应要素时使用。除页面和正文格式外,处理版头、主体、版记、页码、印章或签名章。生成器排版用户提供的文字字段;印章和签名章可在生成后的 Word/WPS 文件中按需插入。
|
||||
|
||||
### 3. 预印红头纸套打与完整电子版
|
||||
|
||||
预印红头纸套打是常见制作流程:DOCX 首页预留纸上已印制的机关标志、红线等区域,打印时放入对应红头纸;不在文档中重复绘制这些红色元素。标准规定最终版式,不规定只能使用预印纸。完整电子红头仅在用户明确要求时生成。套打区域高度以实际纸样为准,不能将“35mm”误作全部预留高度。
|
||||
|
||||
## 三、纸张、版面和印制
|
||||
|
||||
@@ -142,10 +146,12 @@
|
||||
| 报告居中页码 | 是 | 无 |
|
||||
| 正式单双页页码 | 是 | `--page-number standard` |
|
||||
| 普通表格 | 是 | 横排表格方向和复杂跨页表格复核 |
|
||||
| 红色机构名称、文号 | 是 | `--org`、`--doc-no` |
|
||||
| 普通材料 | 是 | `--format ordinary`,不绘制红头 |
|
||||
| 预印红头纸套打 | 是 | `--format formal --letterhead preprinted`,按纸样设置预留高度 |
|
||||
| 完整电子红头 | 是 | `--format formal --letterhead digital` |
|
||||
| 印章、签名章 | 文字版式之外 | 在 Word/WPS 中插入图片或实体盖章 |
|
||||
| 版记 | 可编辑 | 按实际字段补充 |
|
||||
| 信函、命令、纪要 | 可编辑 | 按实际版式继续调整 |
|
||||
| 版记 | 是 | `--cc`、`--print-org`、`--print-date` 适用时生成 |
|
||||
| 信函、命令、纪要 | 是 | `--format letter|command|minutes`,按实际字段生成 |
|
||||
|
||||
## 十、建议检查
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ function preferredFont(candidates) {
|
||||
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" },
|
||||
body: { eastAsia: preferredFont(["仿宋", "仿宋_GB2312", "FangSong", "FangSong_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: "宋体" },
|
||||
@@ -40,11 +40,13 @@ const FONT = {
|
||||
|
||||
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]
|
||||
node generate_gongwen_docx.mjs --input source.md --output out.docx [--format ordinary|formal|letter|command|minutes] [--letterhead preprinted|digital] [--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.
|
||||
- ordinary is the default for reports, plans and other editable materials. It never creates a red header from --org alone.
|
||||
- formal is used only when a formal issuing-document layout is explicitly requested. It defaults to preprinted letterhead: red elements are reserved, not redrawn. Use --letterhead digital only for a complete electronic red-head layout.
|
||||
- letter, command and minutes use their dedicated national-standard layout branches. Their required fields must be supplied explicitly.
|
||||
- --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.`);
|
||||
@@ -112,7 +114,10 @@ function paragraph(text, opts = {}) {
|
||||
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>`;
|
||||
const style = opts.style ? `<w:pStyle w:val="${attr(opts.style)}"/>` : "";
|
||||
const outline = opts.outlineLevel === undefined ? "" : `<w:outlineLvl w:val="${opts.outlineLevel}"/>`;
|
||||
const pageBreak = opts.pageBreakBefore ? "<w:pageBreakBefore/>" : "";
|
||||
const pPr = `<w:pPr>${style}${keepNext}${pageBreak}${align}${indent}${outline}<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>`;
|
||||
}
|
||||
|
||||
@@ -124,6 +129,7 @@ function titleParagraph(text) {
|
||||
size: "44",
|
||||
before: "0",
|
||||
after: "560",
|
||||
style: "GongwenTitle",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -134,17 +140,19 @@ function agencyMarkParagraph(text) {
|
||||
fontPreset: "title",
|
||||
size: "56",
|
||||
color: "FF0000",
|
||||
after: "280",
|
||||
after: "560",
|
||||
before: "5953",
|
||||
});
|
||||
}
|
||||
|
||||
function documentNumberParagraph(text) {
|
||||
function documentNumberParagraph(text, upward = false) {
|
||||
return paragraph(text, {
|
||||
align: "left",
|
||||
align: upward ? "left" : "center",
|
||||
indent: false,
|
||||
fontPreset: "body",
|
||||
size: "32",
|
||||
after: "160",
|
||||
after: "227",
|
||||
left: upward ? FIRST_LINE_INDENT : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -175,6 +183,8 @@ function h1(text) {
|
||||
keepNext: true,
|
||||
before: "160",
|
||||
after: "0",
|
||||
style: "Heading1",
|
||||
outlineLevel: 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -186,6 +196,8 @@ function h2(text) {
|
||||
keepNext: true,
|
||||
before: "80",
|
||||
after: "0",
|
||||
style: "Heading2",
|
||||
outlineLevel: 1,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -197,6 +209,8 @@ function h3(text) {
|
||||
keepNext: true,
|
||||
before: "40",
|
||||
after: "0",
|
||||
style: "Heading3",
|
||||
outlineLevel: 2,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -208,6 +222,8 @@ function h4(text) {
|
||||
keepNext: true,
|
||||
before: "0",
|
||||
after: "0",
|
||||
style: "Heading4",
|
||||
outlineLevel: 3,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -215,6 +231,32 @@ function right(text) {
|
||||
return paragraph(text, { align: "right", indent: false, right: FIRST_LINE_INDENT });
|
||||
}
|
||||
|
||||
function redRule(thickness = "8") {
|
||||
return `<w:p><w:pPr><w:spacing w:before="0" w:after="0" w:line="80" w:lineRule="exact"/></w:pPr><w:r><w:rPr><w:color w:val="FF0000"/></w:rPr><w:pict><v:rect xmlns:v="urn:schemas-microsoft-com:vml" style="width:156mm;height:${thickness === "8" ? "0.8mm" : "0.35mm"}" fillcolor="#FF0000" stroked="f"/></w:pict></w:r></w:p>`;
|
||||
}
|
||||
|
||||
function headerFieldParagraph(text, fontPreset = "h1", opts = {}) {
|
||||
return paragraph(text, { align: opts.align ?? "left", indent: false, fontPreset, size: "32", after: opts.after ?? "0", left: opts.left, right: opts.right });
|
||||
}
|
||||
|
||||
function attachmentNote(text) {
|
||||
return paragraph(`附件:${text}`, { align: "left", fontPreset: "body", size: "32", before: "560", firstLine: FIRST_LINE_INDENT, left: "0" });
|
||||
}
|
||||
|
||||
function colophon(args) {
|
||||
if (!args["cc"] && !args["print-org"] && !args["print-date"]) return [];
|
||||
const body = [redRule("4")];
|
||||
if (args.cc) body.push(paragraph(`抄送:${args.cc.replace(/[。.]?$/, "")}。`, { align: "left", indent: false, left: "320", right: "320", fontPreset: "body", size: "28" }));
|
||||
if (args.cc) body.push(redRule("2"));
|
||||
if (args["print-org"] || args["print-date"]) {
|
||||
const left = args["print-org"] ?? "";
|
||||
const rightValue = args["print-date"] ? `${args["print-date"].replace(/印发$/, "")}印发` : "";
|
||||
body.push(`<w:p><w:pPr><w:jc w:val="both"/><w:ind w:left="320" w:right="320"/><w:spacing w:before="0" w:after="0" w:line="560" w:lineRule="exact"/></w:pPr>${run(left, { fontPreset: "body", size: "28" })}<w:r><w:tab/></w:r>${run(rightValue, { fontPreset: "body", size: "28" })}</w:p>`);
|
||||
}
|
||||
body.push(redRule("4"));
|
||||
return body;
|
||||
}
|
||||
|
||||
function emptyLine() {
|
||||
return `<w:p><w:pPr><w:spacing w:line="560" w:lineRule="exact"/></w:pPr></w:p>`;
|
||||
}
|
||||
@@ -371,16 +413,20 @@ function renderHeading(block) {
|
||||
function renderParagraph(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);
|
||||
return paragraph(block.text);
|
||||
}
|
||||
|
||||
function buildDocument(blocks, args) {
|
||||
let title = args.title;
|
||||
const format = ["ordinary", "formal", "letter", "command", "minutes"].includes(args.format) ? args.format : "ordinary";
|
||||
const letterhead = args.letterhead === "digital" ? "digital" : "preprinted";
|
||||
const pageNumberMode = args["no-page-number"] || args["no-page-numbers"]
|
||||
? "none"
|
||||
: ["center", "standard", "none"].includes(args["page-number"])
|
||||
? args["page-number"]
|
||||
: "center";
|
||||
: format === "formal" || format === "letter" || format === "command" || format === "minutes" ? "standard" : "center";
|
||||
const body = [];
|
||||
const sourceBlocks = [...blocks];
|
||||
if (!title && sourceBlocks[0]?.type === "heading") {
|
||||
@@ -395,8 +441,36 @@ function buildDocument(blocks, args) {
|
||||
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"]));
|
||||
const upward = args["upward"] === "true" || args["upward"] === true;
|
||||
const formal = format === "formal";
|
||||
if (formal) {
|
||||
if (args["copy-no"]) body.push(headerFieldParagraph(String(args["copy-no"]).padStart(6, "0"), "body"));
|
||||
if (args.secret) body.push(headerFieldParagraph(args.secret));
|
||||
if (args.urgent) body.push(headerFieldParagraph(args.urgent));
|
||||
if (letterhead === "digital" && args.org) body.push(agencyMarkParagraph(args.org));
|
||||
if (letterhead === "preprinted") {
|
||||
const reserve = Number(args["letterhead-reserve-mm"] ?? 72);
|
||||
if (!Number.isFinite(reserve) || reserve < 37 || reserve > 130) throw new Error("--letterhead-reserve-mm must be between 37 and 130");
|
||||
body.push(paragraph("", { indent: false, before: String(Math.round((reserve - 37) * 56.7)), after: "0", fontPreset: "body" }));
|
||||
}
|
||||
if (args["doc-no"]) body.push(documentNumberParagraph(args["doc-no"], upward));
|
||||
if (upward && args.signer) {
|
||||
body.push(headerFieldParagraph(`签发人:${args.signer}`, "h2", { align: "right", right: FIRST_LINE_INDENT }));
|
||||
}
|
||||
if (letterhead === "digital" || args["preprinted-rule"] === "true") body.push(redRule());
|
||||
} else if (format === "letter") {
|
||||
if (!args.org) throw new Error("letter format requires --org");
|
||||
body.push(paragraph(args.org, { align: "center", indent: false, fontPreset: "title", size: "44", color: "FF0000", before: "4252", after: "227" }));
|
||||
body.push(redRule());
|
||||
if (args["doc-no"]) body.push(headerFieldParagraph(args["doc-no"], "body", { align: "right" }));
|
||||
} else if (format === "command") {
|
||||
if (!args.org) throw new Error("command format requires --org");
|
||||
body.push(paragraph(args.org, { align: "center", indent: false, fontPreset: "title", size: "44", color: "FF0000", before: "3402", after: "560" }));
|
||||
if (args["doc-no"]) body.push(paragraph(args["doc-no"], { align: "center", indent: false, fontPreset: "body", size: "32", after: "560" }));
|
||||
} else if (format === "minutes") {
|
||||
if (!args.org) throw new Error("minutes format requires --org (for XXXXX纪要)");
|
||||
body.push(paragraph(args.org, { align: "center", indent: false, fontPreset: "title", size: "44", color: "FF0000", before: "5953", after: "560" }));
|
||||
}
|
||||
if (title) body.push(titleParagraph(title));
|
||||
if (args.subtitle) body.push(subtitleParagraph(args.subtitle));
|
||||
if (mainSend) body.push(mainSendParagraph(mainSend));
|
||||
@@ -413,22 +487,39 @@ function buildDocument(blocks, args) {
|
||||
}
|
||||
body.push(renderParagraph(block));
|
||||
}
|
||||
if (args["attachment-note"]) body.push(attachmentNote(args["attachment-note"]));
|
||||
if (args.sender || args.date) {
|
||||
body.push(emptyLine());
|
||||
if (args["seal-mode"] === "signed") {
|
||||
if (!args.signer || !args["signer-title"]) throw new Error("signed seal mode requires --signer and --signer-title");
|
||||
body.push(right(`${args["signer-title"]} ${args.signer}`));
|
||||
if (args.date) body.push(right(args.date));
|
||||
} else if (args["seal-mode"] === "seal") {
|
||||
if (args.sender) body.push(right(args.sender));
|
||||
if (args.date) body.push(right(args.date));
|
||||
} else {
|
||||
if (args.sender) body.push(right(args.sender));
|
||||
if (args.date) body.push(right(args.date));
|
||||
}
|
||||
}
|
||||
if (args.note) body.push(paragraph(`(${args.note.replace(/^(|)$/g, "")})`, { align: "left", fontPreset: "body", size: "32" }));
|
||||
if (format === "minutes" && args.attendees) body.push(paragraph(`出席:${args.attendees}`, { align: "left", fontPreset: "body", size: "32", before: "560" }));
|
||||
if (format === "minutes" && args.absent) body.push(paragraph(`请假:${args.absent}`, { align: "left", fontPreset: "body", size: "32" }));
|
||||
if (format === "minutes" && args.observers) body.push(paragraph(`列席:${args.observers}`, { align: "left", fontPreset: "body", size: "32" }));
|
||||
if (format !== "letter") body.push(...colophon(args));
|
||||
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>`;
|
||||
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" xmlns:v="urn:schemas-microsoft-com:vml"><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>`;
|
||||
const style = (id, name, preset, size, outline, before = "0") => `<w:style w:type="paragraph" w:customStyle="1" w:styleId="${id}"><w:name w:val="${name}"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:qFormat/><w:pPr><w:keepNext/><w:ind w:firstLine="640"/><w:outlineLvl w:val="${outline}"/><w:spacing w:before="${before}" w:after="0" w:line="560" w:lineRule="exact"/></w:pPr><w:rPr><w:rFonts ${fontAttrs({ fontPreset: preset })}/><w:color w:val="000000"/><w:sz w:val="${size}"/><w:szCs w:val="${size}"/><w:lang w:val="en-US" w:eastAsia="zh-CN"/></w:rPr></w:style>`;
|
||||
const title = `<w:style w:type="paragraph" w:customStyle="1" w:styleId="GongwenTitle"><w:name w:val="公文标题"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:qFormat/><w:pPr><w:jc w:val="center"/><w:spacing w:before="0" w:after="560" w:line="560" w:lineRule="exact"/></w:pPr><w:rPr><w:rFonts ${fontAttrs({ fontPreset: "title" })}/><w:sz w:val="44"/><w:szCs w:val="44"/></w:rPr></w:style>`;
|
||||
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>${title}${style("Heading1", "标题 1", "h1", "32", 0, "160")}${style("Heading2", "标题 2", "h2", "32", 1, "80")}${style("Heading3", "标题 3", "body", "32", 2, "40")}${style("Heading4", "标题 4", "body", "32", 3)}</w:styles>`;
|
||||
}
|
||||
|
||||
function fontDef(name, altName, family = "roman", charset = "86") {
|
||||
|
||||
@@ -76,6 +76,7 @@ package_traework() {
|
||||
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/"
|
||||
rm -f "$output"
|
||||
(cd "$stage" && zip -qr "$output" gongwen)
|
||||
unzip -t "$output" >/dev/null
|
||||
rm -rf "$stage"
|
||||
|
||||
@@ -4,7 +4,7 @@ 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]");
|
||||
console.log("Usage: node verify_gongwen_docx.mjs --input file.docx [--profile ordinary|formal|letter|command|minutes] [--letterhead preprinted|digital]");
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
@@ -18,74 +18,62 @@ function parseArgs(argv) {
|
||||
}
|
||||
|
||||
function readPart(file, part, optional = false) {
|
||||
try {
|
||||
return execFileSync("unzip", ["-p", file, part], { encoding: "utf8" });
|
||||
} catch (error) {
|
||||
if (optional) return "";
|
||||
throw error;
|
||||
}
|
||||
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, "")));
|
||||
}
|
||||
function paragraphs(xml) { return xml.match(/<w:p(?:\s[^>]*)?>[\s\S]*?<\/w:p>/g) ?? []; }
|
||||
|
||||
const args = parseArgs(process.argv);
|
||||
if (!args.input || !fs.existsSync(args.input)) {
|
||||
usage();
|
||||
process.exit(2);
|
||||
}
|
||||
if (!args.input || !fs.existsSync(args.input)) { usage(); process.exit(2); }
|
||||
const profile = args.profile ?? "ordinary";
|
||||
if (!["ordinary", "formal", "letter", "command", "minutes"].includes(profile)) { console.error("Unsupported profile."); 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 footerOdd = readPart(args.input, "word/footerOdd.xml", true);
|
||||
const footerEven = readPart(args.input, "word/footerEven.xml", true);
|
||||
const footerCenter = readPart(args.input, "word/footerCenter.xml", true);
|
||||
const checks = [];
|
||||
|
||||
function check(name, ok, detail) {
|
||||
checks.push({ name, ok, detail });
|
||||
}
|
||||
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");
|
||||
check("Document grid", /<w:docGrid[^>]*w:linePitch="560"/.test(doc), "28 pt implementation grid");
|
||||
check("Default body font", /w:eastAsia="(?:仿宋|仿宋_GB2312|FangSong|FangSong_GB2312|STFangsong)"/.test(styles) && /<w:sz w:val="32"/.test(styles), "3rd-size FangSong requested");
|
||||
check("Heading styles", [1, 2, 3, 4].every((level) => new RegExp(`w:styleId="Heading${level}"`).test(styles)), "Heading 1 through Heading 4 are defined");
|
||||
|
||||
const textOf = (xml) => xml.replace(/<[^>]+>/g, "");
|
||||
const headingRules = [
|
||||
[/^[一二三四五六七八九十百]+、/, "Heading1", /(?:黑体|SimHei|Heiti SC|STHeiti)/],
|
||||
[/^([一二三四五六七八九十百]+)/, "Heading2", /(?:楷体|楷体_GB2312|KaiTi|KaiTi_GB2312|Kaiti SC|STKaiti)/],
|
||||
[/^\d+[..]/, "Heading3", /(?:仿宋|仿宋_GB2312|FangSong|FangSong_GB2312|STFangsong)/],
|
||||
[/^(\d+)/, "Heading4", /(?:仿宋|仿宋_GB2312|FangSong|FangSong_GB2312|STFangsong)/],
|
||||
];
|
||||
for (const [pattern, style, font] of headingRules) {
|
||||
const matches = paragraphs(doc).filter((p) => pattern.test(textOf(p).trim()));
|
||||
check(`${style} applied when present`, matches.every((p) => new RegExp(`w:pStyle w:val="${style}"`).test(p) && /w:firstLine="640"/.test(p) && font.test(p)), matches.length ? `${matches.length} matching paragraph(s)` : "not present in this document");
|
||||
}
|
||||
|
||||
if (profile === "ordinary") {
|
||||
check("No formal red-head drawing", !/w:color w:val="FF0000"/.test(doc), "ordinary output has no red head or red rule");
|
||||
check("Centered layout footer", /w:jc w:val="center"/.test(footerCenter) && /w:instr="PAGE"/.test(footerCenter), "ordinary output uses centered page number");
|
||||
} else if (profile === "letter") {
|
||||
check("Letter first-page page number", !footerCenter && !footerOdd && !footerEven, "letter test output suppresses first-page page number");
|
||||
} 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}`);
|
||||
check("Odd/even page fields", /w:instr="PAGE"/.test(footerOdd) && /w:instr="PAGE"/.test(footerEven), "odd and even page footers exist");
|
||||
check("Odd/even page positions", /w:jc w:val="right"/.test(footerOdd) && /w:jc w:val="left"/.test(footerEven), "odd right and even left");
|
||||
}
|
||||
if (profile === "formal") {
|
||||
if (args.letterhead === "digital") {
|
||||
check("Digital red head", /w:color w:val="FF0000"/.test(doc), "digital formal output contains requested red elements");
|
||||
} else {
|
||||
check("Preprinted letterhead reserve", /w:before="\d{4,}"/.test(doc), "first-page top reserve is present");
|
||||
check("No red drawing for preprinted letterhead", !/w:color w:val="FF0000"/.test(doc), "preprinted mode does not redraw red letterhead or rule");
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
if (failed.length) { console.error(`Verification failed: ${failed.length} item(s).`); process.exit(1); }
|
||||
console.log(`Verification passed (${profile} profile): ${checks.length} item(s).`);
|
||||
|
||||
+13
-5
@@ -13,15 +13,23 @@ 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
|
||||
"$NODE" "$GEN" --input "$FIXTURE" --output "$TMP_DIR/layout.docx" --format ordinary --org "不应变红头的单位" --title "公文格式回归测试" --page-number center
|
||||
"$NODE" "$VERIFY" --input "$TMP_DIR/layout.docx" --profile ordinary
|
||||
unzip -t "$TMP_DIR/layout.docx" >/dev/null
|
||||
! unzip -p "$TMP_DIR/layout.docx" word/document.xml | grep -q 'FF0000'
|
||||
unzip -p "$TMP_DIR/layout.docx" word/styles.xml | grep -q 'styleId="Heading4"'
|
||||
unzip -p "$TMP_DIR/layout.docx" word/document.xml | grep -q 'w:pStyle w:val="Heading4"'
|
||||
|
||||
"$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 '示例单位文件'
|
||||
"$NODE" "$GEN" --input "$FIXTURE" --output "$TMP_DIR/standard.docx" --format formal --letterhead preprinted --letterhead-reserve-mm 72 --org "示例单位文件" --doc-no "示例发〔2026〕1号" --title "公文格式回归测试" --page-number standard
|
||||
"$NODE" "$VERIFY" --input "$TMP_DIR/standard.docx" --profile formal --letterhead preprinted
|
||||
! unzip -p "$TMP_DIR/standard.docx" word/document.xml | grep -q '示例单位文件'
|
||||
unzip -p "$TMP_DIR/standard.docx" word/document.xml | grep -q '示例发〔2026〕1号'
|
||||
|
||||
"$NODE" "$GEN" --input "$FIXTURE" --output "$TMP_DIR/digital.docx" --format formal --letterhead digital --org "示例单位文件" --doc-no "示例发〔2026〕1号" --title "电子红头测试" --page-number standard
|
||||
"$NODE" "$VERIFY" --input "$TMP_DIR/digital.docx" --profile formal --letterhead digital
|
||||
unzip -p "$TMP_DIR/digital.docx" word/document.xml | grep -q '示例单位文件'
|
||||
unzip -p "$TMP_DIR/digital.docx" word/document.xml | grep -q 'FF0000'
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user