严格化国标版头与全条款核验
This commit is contained in:
@@ -9,14 +9,18 @@ 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;
|
||||
// Word frame coordinates are relative to the type-area margin in the Office
|
||||
// and LibreOffice renderers used by this skill. These values keep first-page
|
||||
// header fields and the centered agency mark independent of one another.
|
||||
const HEADER_FIELD_BEFORE = 1760;
|
||||
const AGENCY_FRAME_BEFORE = 2015;
|
||||
const ONE_CHAR_INDENT = FIRST_LINE_INDENT / 2;
|
||||
// First-page red-head frames use the type-area top as their vertical anchor.
|
||||
// GB/T 9704-2012 7.2.1-7.2.4 then become direct coordinates: the first
|
||||
// header line is y=0 and the agency mark begins 35 mm below it. Keeping the
|
||||
// paragraph spacing at zero prevents optional left-corner fields from moving
|
||||
// the centred mark or changing the preprinted-paper reserve.
|
||||
const MM_TO_DXA = 56.692913;
|
||||
const HEADER_FIELD_BEFORE = 0;
|
||||
const AGENCY_FRAME_BEFORE = 0;
|
||||
const AGENCY_FLOW_BEFORE = 1880;
|
||||
const HEADER_FIELD_Y = 275;
|
||||
const AGENCY_FRAME_Y = 2126;
|
||||
const HEADER_FIELD_Y = 0;
|
||||
const AGENCY_FRAME_Y = Math.round(35 * MM_TO_DXA);
|
||||
|
||||
function installedFontFamilies() {
|
||||
try {
|
||||
@@ -215,7 +219,7 @@ function floatingHeaderFieldParagraph(text, fontPreset, index) {
|
||||
fontPreset,
|
||||
size: "32",
|
||||
before: HEADER_FIELD_BEFORE,
|
||||
frame: { hAnchor: "margin", vAnchor: "page", xAlign: "left", y: HEADER_FIELD_Y + index * 560, w: CONTENT_W, h: 560 },
|
||||
frame: { hAnchor: "margin", vAnchor: "margin", xAlign: "left", y: HEADER_FIELD_Y + index * 560, w: CONTENT_W, h: 560 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -227,7 +231,7 @@ function floatingAgencyMarkParagraph(text) {
|
||||
size: "56",
|
||||
color: "FF0000",
|
||||
before: AGENCY_FRAME_BEFORE,
|
||||
frame: { hAnchor: "margin", vAnchor: "page", xAlign: "center", y: AGENCY_FRAME_Y, w: CONTENT_W, h: 800 },
|
||||
frame: { hAnchor: "margin", vAnchor: "margin", xAlign: "center", y: AGENCY_FRAME_Y, w: CONTENT_W, h: 800 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -270,11 +274,11 @@ function documentNumberParagraph(text, upward = false) {
|
||||
}
|
||||
|
||||
function upwardHeaderParagraph(docNo, signer) {
|
||||
const tabPosition = CONTENT_W - FIRST_LINE_INDENT;
|
||||
const tabPosition = CONTENT_W - ONE_CHAR_INDENT;
|
||||
const signers = String(signer).split(/[、,,;;\s]+/).map((name) => name.trim()).filter(Boolean);
|
||||
if (signers.length > 2) throw new Error("upward --signer supports up to two names; use the authority's joint-signature template for more signers");
|
||||
const signerRuns = signers.map((name, index) => `${index ? run(" ", { fontPreset: "h2", size: "32" }) : ""}${run(name, { fontPreset: "h2", size: "32" })}`).join("");
|
||||
return `<w:p><w:pPr><w:jc w:val="both"/><w:ind w:left="${FIRST_LINE_INDENT}" w:right="${FIRST_LINE_INDENT}"/><w:tabs><w:tab w:val="right" w:pos="${tabPosition}"/></w:tabs><w:spacing w:before="0" w:after="227" w:line="560" w:lineRule="exact"/><w:adjustRightInd w:val="true"/><w:snapToGrid w:val="true"/><w:kinsoku w:val="true"/></w:pPr>${run(docNo, { fontPreset: "body", size: "32" })}<w:r><w:tab/></w:r>${run("签发人:", { fontPreset: "body", size: "32" })}${signerRuns}</w:p>`;
|
||||
return `<w:p><w:pPr><w:jc w:val="both"/><w:ind w:left="${ONE_CHAR_INDENT}" w:right="${ONE_CHAR_INDENT}"/><w:tabs><w:tab w:val="right" w:pos="${tabPosition}"/></w:tabs><w:spacing w:before="0" w:after="227" w:line="560" w:lineRule="exact"/><w:adjustRightInd w:val="true"/><w:snapToGrid w:val="true"/><w:kinsoku w:val="true"/></w:pPr>${run(docNo, { fontPreset: "body", size: "32" })}<w:r><w:tab/></w:r>${run("签发人:", { fontPreset: "body", size: "32" })}${signerRuns}</w:p>`;
|
||||
}
|
||||
|
||||
function subtitleParagraph(text) {
|
||||
@@ -333,6 +337,40 @@ function normalizeAttachmentNote(text) {
|
||||
return `附件:${names}`;
|
||||
}
|
||||
|
||||
function normalizedAttachmentName(text) {
|
||||
return String(text ?? "")
|
||||
.trim()
|
||||
.replace(/^附件[::]\s*/, "")
|
||||
.replace(/^附件\s*[一二三四五六七八九十百\d]+\s*[::]\s*/, "")
|
||||
.replace(/^\d+\s*[..、]\s*/, "")
|
||||
.replace(/[。;;]+$/, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function attachmentNamesFromNote(text) {
|
||||
return normalizeAttachmentNote(text)
|
||||
.replace(/^附件:/, "")
|
||||
.split(/[;;]/)
|
||||
.map(normalizedAttachmentName)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function attachmentTitleFromFile(filePath) {
|
||||
const blocks = parseMarkdown(fs.readFileSync(filePath, "utf8"));
|
||||
const heading = blocks.find((block) => block.type === "heading");
|
||||
return normalizedAttachmentName(heading?.text ?? path.basename(filePath, path.extname(filePath)));
|
||||
}
|
||||
|
||||
function validateAttachmentConsistency(note, files) {
|
||||
if (!files.length) return;
|
||||
if (!note) throw new Error("--attachment-file requires --attachment-note so attachment order and titles can be checked");
|
||||
const noted = attachmentNamesFromNote(note);
|
||||
const actual = files.map(attachmentTitleFromFile);
|
||||
if (noted.length !== actual.length || noted.some((name, index) => name !== actual[index])) {
|
||||
throw new Error(`--attachment-note must match attachment titles and order: note=[${noted.join("、")}] files=[${actual.join("、")}]`);
|
||||
}
|
||||
}
|
||||
|
||||
function h1(text) {
|
||||
return paragraph(text, {
|
||||
align: "left",
|
||||
@@ -419,6 +457,11 @@ function headerFieldParagraph(text, fontPreset = "h1", opts = {}) {
|
||||
return paragraph(text, { align: opts.align ?? "left", indent: false, fontPreset, size: "32", before: opts.before ?? "0", after: opts.after ?? "0", left: opts.left, right: opts.right });
|
||||
}
|
||||
|
||||
function letterHeaderRow(leftText, docNo) {
|
||||
const cell = (text, align, fontPreset) => `<w:tc><w:tcPr><w:tcW w:w="4422" w:type="dxa"/><w:tcMar><w:top w:w="0" w:type="dxa"/><w:left w:w="0" w:type="dxa"/><w:bottom w:w="0" w:type="dxa"/><w:right w:w="0" w:type="dxa"/></w:tcMar></w:tcPr>${paragraph(text, { align, indent: false, fontPreset, size: "32", before: "280", after: "0" })}</w:tc>`;
|
||||
return `<w:tbl><w:tblPr><w:tblW w:w="${CONTENT_W}" w:type="dxa"/><w:tblBorders><w:top w:val="nil"/><w:left w:val="nil"/><w:bottom w:val="nil"/><w:right w:val="nil"/><w:insideH w:val="nil"/><w:insideV w:val="nil"/></w:tblBorders><w:tblCellMar><w:top w:w="0" w:type="dxa"/><w:left w:w="0" w:type="dxa"/><w:bottom w:w="0" w:type="dxa"/><w:right w:w="0" w:type="dxa"/></w:tblCellMar></w:tblPr><w:tblGrid><w:gridCol w:w="4422"/><w:gridCol w:w="4422"/></w:tblGrid><w:tr>${cell(leftText, "left", "body")}${cell(docNo, "right", "body")}</w:tr></w:tbl>`;
|
||||
}
|
||||
|
||||
function attachmentNote(text) {
|
||||
return paragraph(normalizeAttachmentNote(text), { align: "left", fontPreset: "body", size: "32", before: "560", firstLine: "-960", left: "1600" });
|
||||
}
|
||||
@@ -431,7 +474,7 @@ function signatureParagraphs(args) {
|
||||
if (args["seal-mode"] === "signed") {
|
||||
if (!args.signer || !args["signer-title"]) throw new Error("signed seal mode requires --signer and --signer-title");
|
||||
const lines = [rightWithIndent(`${args["signer-title"]} ${args.signer}`, FIRST_LINE_INDENT * 2)];
|
||||
if (args.date) lines.push(rightWithIndent(args.date, FIRST_LINE_INDENT * 2));
|
||||
if (args.date) lines.push(...blankGridLines(1), rightWithIndent(args.date, FIRST_LINE_INDENT * 2));
|
||||
return lines;
|
||||
}
|
||||
if (args["seal-mode"] === "seal") {
|
||||
@@ -729,6 +772,7 @@ function buildDocument(blocks, args) {
|
||||
if (args["copy-no"] && !/^\d{1,6}$/.test(String(args["copy-no"]))) throw new Error("--copy-no must contain one to six Arabic digits");
|
||||
let headerFieldIndex = 0;
|
||||
if (args["copy-no"]) body.push(floatingHeaderFieldParagraph(String(args["copy-no"]).padStart(6, "0"), "body", headerFieldIndex++));
|
||||
else if (args.secret) body.push(floatingHeaderFieldParagraph("", "body", headerFieldIndex++));
|
||||
if (args.secret) body.push(floatingHeaderFieldParagraph(args.secret, "h1", headerFieldIndex++));
|
||||
if (args.urgent) body.push(floatingHeaderFieldParagraph(args.urgent, "h1", headerFieldIndex++));
|
||||
if (letterhead === "digital" && args.org) {
|
||||
@@ -754,15 +798,26 @@ function buildDocument(blocks, args) {
|
||||
titleGapLines = 2;
|
||||
} else if (format === "letter") {
|
||||
if (!args.org) throw new Error("letter format requires --org");
|
||||
if (args["copy-no"] && !/^\d{1,6}$/.test(String(args["copy-no"]))) throw new Error("--copy-no must contain one to six Arabic digits");
|
||||
body.push(paragraph(args.org, { align: "center", indent: false, fontPreset: "title", size: "44", color: "FF0000", before: "0", after: "227" }));
|
||||
body.push(redDoubleRule({ upperMm: "0.35", lowerMm: "0.25" }));
|
||||
if (args["doc-no"]) {
|
||||
args["doc-no"] = normalizeDocumentNumber(args["doc-no"]);
|
||||
body.push(headerFieldParagraph(args["doc-no"], "body", { align: "right", before: "280" }));
|
||||
if (args["doc-no"]) args["doc-no"] = normalizeDocumentNumber(args["doc-no"]);
|
||||
const letterFields = [];
|
||||
if (args["copy-no"]) letterFields.push({ text: String(args["copy-no"]).padStart(6, "0"), fontPreset: "body" });
|
||||
else if (args.secret) letterFields.push({ text: "", fontPreset: "body" });
|
||||
if (args.secret) letterFields.push({ text: args.secret, fontPreset: "h1" });
|
||||
if (args.urgent) letterFields.push({ text: args.urgent, fontPreset: "h1" });
|
||||
if (letterFields.length || args["doc-no"]) {
|
||||
const first = letterFields[0] ?? { text: "", fontPreset: "body" };
|
||||
body.push(letterHeaderRow(first.text, args["doc-no"] ?? ""));
|
||||
for (const field of letterFields.slice(1)) {
|
||||
body.push(headerFieldParagraph(field.text, field.fontPreset));
|
||||
}
|
||||
}
|
||||
titleGapLines = 2;
|
||||
} else if (format === "command") {
|
||||
if (!args.org) throw new Error("command format requires --org");
|
||||
if (!/(?:命令|令)$/.test(String(args.org).trim())) throw new Error("command --org must end with“命令”or“令”");
|
||||
if (!args["doc-no"]) throw new Error("command format requires --doc-no for the 令号");
|
||||
body.push(paragraph(args.org, { align: "center", indent: false, fontPreset: "title", size: "44", color: "FF0000", before: "0", after: "0" }));
|
||||
if (args["doc-no"]) {
|
||||
@@ -801,14 +856,18 @@ function buildDocument(blocks, args) {
|
||||
body.push(attachmentNote(args["attachment-note"]));
|
||||
}
|
||||
if (args.sender || args.date) {
|
||||
body.push(emptyLine());
|
||||
body.push(...(args["seal-mode"] === "signed" ? blankGridLines(2) : [emptyLine()]));
|
||||
body.push(...signatureParagraphs(args));
|
||||
}
|
||||
if (args.note) body.push(paragraph(`(${args.note.replace(/^(|)$/g, "")})`, { align: "left", fontPreset: "body", size: "32" }));
|
||||
const attachmentFiles = Array.isArray(args["attachment-file"]) ? args["attachment-file"] : args["attachment-file"] ? [args["attachment-file"]] : [];
|
||||
attachmentFiles.forEach((filePath, index) => {
|
||||
const absoluteAttachmentFiles = attachmentFiles.map((filePath) => {
|
||||
const absolutePath = path.resolve(filePath);
|
||||
if (!fs.existsSync(absolutePath)) throw new Error(`attachment file not found: ${filePath}`);
|
||||
return absolutePath;
|
||||
});
|
||||
validateAttachmentConsistency(args["attachment-note"], absoluteAttachmentFiles);
|
||||
absoluteAttachmentFiles.forEach((absolutePath, index) => {
|
||||
const attachmentBlocks = parseMarkdown(fs.readFileSync(absolutePath, "utf8"));
|
||||
body.push(attachmentPage(attachmentBlocks, index + 1, absolutePath));
|
||||
});
|
||||
|
||||
+1
-1
@@ -75,7 +75,7 @@ package_traework() {
|
||||
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/"
|
||||
cp -R "$skill_dir/SKILL.md" "$skill_dir/scripts" "$skill_dir/references" "$skill_dir/tests" "$stage/gongwen/"
|
||||
rm -f "$output"
|
||||
(cd "$stage" && zip -qr "$output" gongwen)
|
||||
unzip -t "$output" >/dev/null
|
||||
|
||||
@@ -129,7 +129,15 @@ if (profile === "formal") {
|
||||
check("Red rule width", /style="width:156mm;height:0\.5mm"/.test(doc), "header separator spans the 156 mm type area");
|
||||
const docParagraphs = paragraphs(doc);
|
||||
const agencyIndex = docParagraphs.findIndex((p) => /w:sz w:val="56"/.test(p) && /w:color w:val="FF0000"/.test(p));
|
||||
check("Digital red-head agency position", agencyIndex >= 0 && /w:framePr[^>]*w:hAnchor="margin"[^>]*w:vAnchor="page"[^>]*w:xAlign="center"[^>]*w:y="2126"/.test(docParagraphs[agencyIndex]) && /w:before="2015"/.test(docParagraphs[agencyIndex]), "agency mark is independently fixed at the 35 mm type-area position");
|
||||
// Keep this expected value independent from the generator's calibration
|
||||
// constants. It is 35 mm in twentieths of a point, as required by 7.2.4.
|
||||
const agencyOffsetFromTypeAreaTop = Math.round(35 * 56.692913);
|
||||
check("Digital red-head agency position", agencyIndex >= 0 && new RegExp(`w:framePr[^>]*w:hAnchor="margin"[^>]*w:vAnchor="margin"[^>]*w:xAlign="center"[^>]*w:y="${agencyOffsetFromTypeAreaTop}"`).test(docParagraphs[agencyIndex]) && /w:before="0"/.test(docParagraphs[agencyIndex]), "agency mark is independently fixed 35 mm below the type-area top");
|
||||
const fixedHeaderFrames = docParagraphs
|
||||
.filter((p) => /w:framePr[^>]*w:hAnchor="margin"[^>]*w:vAnchor="margin"/.test(p))
|
||||
.map((p) => Number((p.match(/w:y="(\d+)"/) ?? [])[1]))
|
||||
.filter((value) => Number.isFinite(value));
|
||||
check("Digital red-head field frames", fixedHeaderFrames.every((value, index) => value === index * 560 || value === agencyOffsetFromTypeAreaTop), "left-corner fields use first-line grid positions without changing the agency mark anchor");
|
||||
const docNoIndex = docParagraphs.findIndex((p) => /〔\d{4}〕[1-9]\d*号/.test(textOf(p)));
|
||||
const agencyToDocNo = agencyIndex >= 0 && docNoIndex > agencyIndex ? docParagraphs.slice(agencyIndex + 1, docNoIndex) : [];
|
||||
const agencyFlowSpacer = agencyToDocNo.filter((p) => /w:before="1880"/.test(p));
|
||||
|
||||
Reference in New Issue
Block a user