修正预印红头留白并严格校验参数

This commit is contained in:
mizzlelover
2026-09-11 08:30:00 +08:00
parent 4bf5d00346
commit 6b9d19698f
35 changed files with 41 additions and 3 deletions
+1 -1
View File
@@ -97,7 +97,7 @@ node scripts/generate_gongwen_docx.mjs --input tests/fixture.md --output /tmp/go
node scripts/verify_gongwen_docx.mjs --input /tmp/gongwen.docx --profile ordinary
```
需要正式发文时,必须明确选择版式。下面的命令生成预印红头纸套打稿;首页只留出纸上已有红头和红线区域,DOCX 不会重绘它们。`72` 是默认预留高度,应按本单位实际红头纸改为 `37—130` 毫米。
需要正式发文时,必须明确选择版式。下面的命令生成预印红头纸套打稿;首页只留出纸上已有红头和红线区域,DOCX 不会重绘它们。`--letterhead-reserve-mm` 表示纸张上边缘到预印机关标志区域起点的高度,生成器随后保留两行 28 磅版心空行再排变量文号,标题前再保留红线下两行空行。`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 "示例发〔20261号" --title "公文格式回归测试"
+1 -1
View File
@@ -28,7 +28,7 @@ node scripts/generate_gongwen_docx.mjs --input source.md --output output.docx --
普通材料使用 `--format ordinary`,默认居中页码,不绘制红头。正式发文须显式使用 `--format formal`;其默认 `--letterhead preprinted`,即首面预留红头纸区域、不在 DOCX 中重绘纸上已有的红色机关标志和红线。`--doc-no` 等本次需打印的黑色变量仍可保留。只有明确要求完整电子版时使用 `--letterhead digital`。完整电子红头的机关标志按标准推荐使用小标宋体,标题按标准使用小标宋体;生成器会在缺失时明确警告并记录实际替代字体,需要硬性阻止替代输出时加 `--require-standard-fonts`。红线下标题按28磅版心网格空二行。
```bash
# 预印红头纸套打;72mm 是默认机关标志区域高度,可按实际纸样调为 37—130mm
# 预印红头纸套打;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 "单位发〔20261号" --title "文档标题" --sender "落款单位" --date "2026年9月9日"
# 完整电子红头版,仅在明确要求时使用
BIN
View File
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 266 KiB

After

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 328 KiB

After

Width:  |  Height:  |  Size: 359 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 KiB

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 50 KiB

+1 -1
View File
@@ -25,7 +25,7 @@
### 3. 预印红头纸套打与完整电子版
预印红头纸套打是常见制作流程:DOCX 首页预留纸上已印制的机关标志、红线等区域,打印时放入对应红头纸;不在文档中重复绘制这些红色元素。标准规定最终版式,不规定只能使用预印纸。完整电子红头仅在用户明确要求时生成。套打区域高度以实际纸样为准,不能将“35mm”误作全部预留高度。电子红头的发文机关标志与标题必须在目标环境中使用可核对的小标宋体;若字体不存在,生成器会明确警告并显示替代字体,`--require-standard-fonts` 可阻止继续生成。红色分隔线后的标题按版心网格空二行,不以视觉贴近为准。
预印红头纸套打是常见制作流程:DOCX 首页预留纸上已印制的机关标志、红线等区域,打印时放入对应红头纸;不在文档中重复绘制这些红色元素。标准规定最终版式,不规定只能使用预印纸。完整电子红头仅在用户明确要求时生成。套打起点高度以实际纸样为准,不能将“35mm”误作全部预留高度;生成器在预印机关标志区域后保留两行28磅版心空行,再排变量文号,标题前再保留红线下两行空行。电子红头的发文机关标志与标题必须在目标环境中使用可核对的小标宋体;若字体不存在,生成器会明确警告并显示替代字体,`--require-standard-fonts` 可阻止继续生成。红色分隔线后的标题按版心网格空二行,不以视觉贴近为准。
## 三、纸张、版面和印制
+13
View File
@@ -826,6 +826,10 @@ function buildDocument(blocks, args) {
// separator. Its reserve is a paper-coordinate boundary, so optional
// copy/secret/urgency fields must not shorten it.
body.push(letterheadReserveParagraph(reserve));
// The preprinted paper supplies the agency mark and separator. Keep the
// variable document number below the reserved mark block by preserving
// the same two 28-point grid lines used under a digital agency mark.
if (args["doc-no"]) body.push(...blankGridLines(2));
}
if (args["doc-no"]) args["doc-no"] = normalizeDocumentNumber(args["doc-no"]);
if (upward && args["doc-no"] && args.signer) body.push(upwardHeaderParagraph(args["doc-no"], args.signer));
@@ -1045,6 +1049,15 @@ if (args.help || !args.input || !args.output) {
usage();
process.exit(args.help ? 0 : 1);
}
const supportedFormats = ["ordinary", "formal", "letter", "command", "minutes", "horizontal-table"];
try {
if (args.format !== undefined && !supportedFormats.includes(args.format)) throw new Error(`--format must be one of ${supportedFormats.join(", ")}`);
if (args.letterhead !== undefined && !["preprinted", "digital"].includes(args.letterhead)) throw new Error("--letterhead must be preprinted or digital");
if (args["page-number"] !== undefined && !["center", "standard", "none"].includes(args["page-number"])) throw new Error("--page-number must be center, standard, or none");
} catch (error) {
console.error(`GENERATOR ERROR: ${error.message}`);
process.exit(2);
}
try {
reportFontStatus(args);
} catch (error) {
+3
View File
@@ -170,6 +170,9 @@ if (profile === "formal") {
const titleXmlStart = firstTitleIndex < 0 ? doc.length : doc.indexOf(preprintedParagraphs[firstTitleIndex]);
const preprintedHeaderXml = titleXmlStart < 0 ? doc : doc.slice(0, titleXmlStart);
check("No red drawing for preprinted letterhead", !/w:color w:val="FF0000"/.test(preprintedHeaderXml), "preprinted mode does not redraw red letterhead or rule");
const preprintedDocNoIndex = preprintedParagraphs.findIndex((p) => /\d{4}[1-9]\d*/.test(textOf(p)));
const preprintedDocNoGap = preprintedDocNoIndex >= 0 ? preprintedParagraphs.slice(0, preprintedDocNoIndex).slice(-2) : [];
check("Preprinted agency-to-document number two blank lines", preprintedDocNoIndex < 0 || (preprintedDocNoGap.length === 2 && preprintedDocNoGap.every(isExplicitBlankGridLine)), "document number starts below the reserved physical agency mark block");
check("Title spacing below physical red rule", hasExplicitTwoLineTitleGap, "title is preceded by two exact 28-point blank paragraphs below the printed red rule");
}
}
+22
View File
@@ -26,6 +26,19 @@ unzip -p "$TMP_DIR/layout.docx" word/document.xml | grep -q 'w:pStyle w:val="Hea
! unzip -p "$TMP_DIR/standard.docx" word/document.xml | grep -q '示例单位文件'
unzip -p "$TMP_DIR/standard.docx" word/document.xml | grep -q '示例发〔20261号'
unzip -p "$TMP_DIR/standard.docx" word/document.xml | grep -q 'w:snapToGrid w:val="false"'
python3 - "$TMP_DIR/standard.docx" <<'PY'
import re
import sys
import zipfile
with zipfile.ZipFile(sys.argv[1]) as archive:
xml = archive.read("word/document.xml").decode()
paragraphs = re.findall(r'<w:p>[\s\S]*?</w:p>', xml)
doc_no_index = next(i for i, paragraph in enumerate(paragraphs) if '示例发〔20261号' in paragraph)
gap = paragraphs[doc_no_index - 2:doc_no_index]
assert len(gap) == 2
assert all('w:line="560"' in paragraph and 'w:lineRule="exact"' in paragraph and 'w:snapToGrid w:val="false"' in paragraph for paragraph in gap)
PY
"$NODE" "$GEN" --input "$FIXTURE" --output "$TMP_DIR/digital.docx" --format formal --letterhead digital --org "示例单位文件" --doc-no "示例发〔20261号" --title "电子红头测试" --page-number standard
"$NODE" "$VERIFY" --input "$TMP_DIR/digital.docx" --profile formal --letterhead digital
@@ -84,6 +97,15 @@ if "$NODE" "$GEN" --input "$FIXTURE" --output "$TMP_DIR/bad-attachment.docx" --f
fi
grep -q 'GENERATOR ERROR:' "$TMP_DIR/bad-attachment.out"
for bad_option in format letterhead page-number; do
output="$TMP_DIR/invalid-$bad_option.out"
if "$NODE" "$GEN" --input "$FIXTURE" --output "$TMP_DIR/invalid-$bad_option.docx" --format ordinary --"$bad_option" "invalid-value" >"$output" 2>&1; then
echo "invalid --$bad_option unexpectedly succeeded" >&2
exit 1
fi
grep -q -- "--$bad_option" "$output"
done
"$NODE" "$GEN" --input "$FIXTURE" --output "$TMP_DIR/command.docx" --format command --org "示例机关命令" --doc-no "第1号" --title "命令正文"
"$NODE" "$VERIFY" --input "$TMP_DIR/command.docx" --profile command