release: 发布 v0.6.2 DOCX 真实文档修复

新增能力:将 DOCX 发布验收拆分为四套独立 140,支持真实语料冻结、指纹复用、失败与基础设施错误独立统计,并为表格换行、全 JSON 围栏、代码连续性和长文档分页建立通用门禁。

问题修复:冻结 Paged.js 分片前的逻辑表格列轨并传递打印几何,统一 Markdown 表格换行、代码、段落与 OOXML 翻译;改进 PDF 文本流排序、语义块映射、颜色与栅格比较,消除窄字符重叠和跨行范围符号误报。

兼容与部署:版本统一为 0.6.2;正式 Docker 镜像内置固定 Chromium、Pandoc 3.9.0.2 和 Serif/Sans/Mono 字体;Desktop NSIS 与 ZIP 继续直接内置字体,无需系统字体安装。

验证结果:合成基线与长庆严格 280/280,M4N 140/140;健康数据残余误报 6/115(5.22%),均核查为重复表头自动对齐/取样误报且基础设施错误为 0。全项目测试、类型检查、生产构建和 git diff --check 通过;正式 Docker、NSIS、ZIP、离线镜像、部署包、清单及 SHA-256 均已生成并校验。
This commit is contained in:
SkyJourney
2026-08-26 10:50:20 +08:00
parent 01b06abc2c
commit 64445322eb
75 changed files with 9255 additions and 298 deletions
+277
View File
@@ -0,0 +1,277 @@
import { execFile } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { promisify } from "node:util";
import {
createDocxReleaseGateFingerprint,
createDocxReleaseGateSuites,
DOCX_RELEASE_GATE_SCHEMA_VERSION,
readJsonIfExists,
resolveDocxReleaseGateSuite,
summarizeSuiteProgress,
writeJsonAtomic
} from "./docx-release-gate-suites.mjs";
const execFileAsync = promisify(execFile);
const repositoryDirectory = path.resolve(import.meta.dirname, "..");
const matrixScript = path.join(repositoryDirectory, "scripts", "verify-docx-layout-visual-matrix.mjs");
const outputRoot = path.resolve(
repositoryDirectory,
process.env.MD_TO_PDF_RELEASE_GATE_OUTPUT_DIR?.trim() ||
"output/docx-release-gate-v0.6.2"
);
const requestedSuiteId = process.env.MD_TO_PDF_RELEASE_GATE_SUITE?.trim() || "next";
const selectedCaseId = process.env.MD_TO_PDF_RELEASE_GATE_CASE?.trim() || undefined;
const allowOutOfOrder =
process.env.MD_TO_PDF_RELEASE_GATE_ALLOW_OUT_OF_ORDER?.trim() === "1";
const requestedCaseLimitText =
process.env.MD_TO_PDF_RELEASE_GATE_CASE_LIMIT?.trim() || undefined;
const requestedCaseLimit = requestedCaseLimitText
? Number.parseInt(requestedCaseLimitText, 10)
: undefined;
if (
requestedCaseLimitText &&
(!Number.isInteger(requestedCaseLimit) || requestedCaseLimit <= 0)
) {
throw new Error("MD_TO_PDF_RELEASE_GATE_CASE_LIMIT 必须是正整数");
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function readAggregates(suites) {
return new Map(suites.map((suite) => {
const aggregate = readJsonIfExists(
path.join(outputRoot, suite.outputDirectoryName, "aggregate.json")
);
const currentFingerprint = createDocxReleaseGateFingerprint(
repositoryDirectory,
suite
);
return [
suite.id,
aggregate?.fingerprint === currentFingerprint ? aggregate : undefined
];
}));
}
function renderSuiteHtml(aggregate) {
const rows = aggregate.cases.map((entry) =>
`<tr><td>${escapeHtml(entry.caseId)}</td><td class="${escapeHtml(entry.status)}">${escapeHtml(entry.status)}</td><td>${escapeHtml(entry.finishedAt || "")}</td><td>${entry.summaryPath ? `<a href="${escapeHtml(entry.summaryPath)}">JSON</a>` : "-"}</td><td>${escapeHtml(entry.error || entry.gateFailures?.join("") || "")}</td></tr>`
).join("");
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>${escapeHtml(aggregate.suite.label)}</title><style>body{font-family:Inter,"Microsoft YaHei",sans-serif;margin:32px;color:#172033}table{border-collapse:collapse;width:100%}th,td{border:1px solid #d0d5dd;padding:8px;text-align:left;vertical-align:top}.passed{background:#e6f7ec}.gate-failed,.error{background:#ffe4e4}</style></head><body><h1>${escapeHtml(aggregate.suite.label)}</h1><p>${aggregate.execution.completedCaseCount}/${aggregate.execution.expectedCaseCount};通过 ${aggregate.execution.passedCaseCount};门禁失败 ${aggregate.execution.gateFailedCaseCount};基础设施错误 ${aggregate.execution.errorCaseCount}。</p><table><thead><tr><th>场景</th><th>状态</th><th>完成时间</th><th>报告</th><th>问题</th></tr></thead><tbody>${rows}</tbody></table></body></html>`;
}
function buildAggregate({ suite, fingerprint, records }) {
const execution = summarizeSuiteProgress({ suite, fingerprint, records });
return {
schemaVersion: DOCX_RELEASE_GATE_SCHEMA_VERSION,
generatedAt: new Date().toISOString(),
fingerprint,
suite: {
id: suite.id,
label: suite.label,
kind: suite.kind,
order: suite.order,
markdownPath: suite.markdownPath,
markdownByteLength: suite.markdownByteLength
},
execution,
gatePassed: execution.gatePassed,
cases: suite.caseIds.map((caseId) => records[caseId])
.filter((record) => record?.fingerprint === fingerprint)
};
}
function writeSuiteReports({ suiteDirectory, suite, fingerprint, records }) {
const aggregate = buildAggregate({ suite, fingerprint, records });
writeJsonAtomic(path.join(suiteDirectory, "progress.json"), {
schemaVersion: DOCX_RELEASE_GATE_SCHEMA_VERSION,
updatedAt: aggregate.generatedAt,
fingerprint,
records
});
writeJsonAtomic(path.join(suiteDirectory, "aggregate.json"), aggregate);
fs.writeFileSync(path.join(suiteDirectory, "aggregate.html"), renderSuiteHtml(aggregate), "utf8");
return aggregate;
}
function writeTopLevelIndex(suites) {
const entries = suites.map((suite) => ({
suite: {
id: suite.id,
label: suite.label,
kind: suite.kind,
order: suite.order,
outputDirectoryName: suite.outputDirectoryName,
markdownByteLength: suite.markdownByteLength
},
aggregate: readJsonIfExists(path.join(outputRoot, suite.outputDirectoryName, "aggregate.json"))
}));
const index = {
schemaVersion: DOCX_RELEASE_GATE_SCHEMA_VERSION,
generatedAt: new Date().toISOString(),
strategy: "four-independent-140-suites",
totalExpectedCaseCount: 560,
entries
};
writeJsonAtomic(path.join(outputRoot, "index.json"), index);
const rows = entries.map(({ suite, aggregate }) =>
`<tr><td>${suite.order + 1}</td><td>${escapeHtml(suite.label)}</td><td>${aggregate?.execution?.completedCaseCount ?? 0}/140</td><td class="${aggregate?.gatePassed ? "passed" : "pending"}">${aggregate?.gatePassed ? "通过" : aggregate ? "未通过/未完成" : "未开始"}</td><td><a href="${escapeHtml(suite.outputDirectoryName)}/aggregate.html">报告</a></td></tr>`
).join("");
fs.writeFileSync(path.join(outputRoot, "index.html"), `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>DOCX 4×140 发布门禁</title><style>body{font-family:Inter,"Microsoft YaHei",sans-serif;margin:32px;color:#172033}table{border-collapse:collapse;min-width:900px}th,td{border:1px solid #d0d5dd;padding:10px;text-align:left}.passed{background:#e6f7ec}.pending{background:#fff4cc}</style></head><body><h1>DOCX 4×140 独立发布门禁</h1><p>先通过标准合成基线,再按 Markdown 字节数从小到大逐套执行。</p><table><thead><tr><th>顺序</th><th>套件</th><th>进度</th><th>状态</th><th>独立报告</th></tr></thead><tbody>${rows}</tbody></table></body></html>`, "utf8");
}
function parseChildSummary(stdout) {
const text = Buffer.isBuffer(stdout) ? stdout.toString("utf8") : String(stdout || "");
return JSON.parse(text.trim());
}
fs.mkdirSync(outputRoot, { recursive: true });
const suites = createDocxReleaseGateSuites(repositoryDirectory);
const suite = resolveDocxReleaseGateSuite({
suites,
requestedId: requestedSuiteId,
aggregates: readAggregates(suites),
allowOutOfOrder
});
if (!suite) {
writeTopLevelIndex(suites);
process.stdout.write("四套独立 140 门禁均已通过,无需重复执行。\n");
process.exit(0);
}
if (selectedCaseId && !suite.caseIds.includes(selectedCaseId)) {
throw new Error(`套件 ${suite.id} 不包含场景 ${selectedCaseId}`);
}
const suiteDirectory = path.join(outputRoot, suite.outputDirectoryName);
const logsDirectory = path.join(suiteDirectory, "logs");
fs.mkdirSync(logsDirectory, { recursive: true });
fs.writeFileSync(
path.join(suiteDirectory, "night-run.pid"),
`${process.pid}\n`,
"utf8"
);
const fingerprint = createDocxReleaseGateFingerprint(repositoryDirectory, suite);
const previousProgress = readJsonIfExists(path.join(suiteDirectory, "progress.json"));
const records = previousProgress?.records && typeof previousProgress.records === "object"
? previousProgress.records
: {};
const runStartedAt = new Date().toISOString();
const runPath = path.join(suiteDirectory, "run.json");
writeJsonAtomic(runPath, {
schemaVersion: DOCX_RELEASE_GATE_SCHEMA_VERSION,
pid: process.pid,
processStartedAt: runStartedAt,
command: process.argv,
suiteId: suite.id,
suiteFingerprint: fingerprint,
allowOutOfOrder,
...(requestedCaseLimit ? { requestedCaseLimit } : {}),
status: "running"
});
const requestedCaseIds = selectedCaseId
? [selectedCaseId]
: requestedCaseLimit
? suite.caseIds.slice(0, requestedCaseLimit)
: suite.caseIds;
for (const caseId of requestedCaseIds) {
if (records[caseId]?.fingerprint === fingerprint && records[caseId]?.status === "passed") {
process.stderr.write(`[DOCX gate ${suite.id}] ${caseId} 已通过,跳过\n`);
continue;
}
process.stderr.write(`[DOCX gate ${suite.id}] 执行 ${caseId}\n`);
const environment = {
...process.env,
MD_TO_PDF_LAYOUT_VISUAL_CASE: caseId,
MD_TO_PDF_LAYOUT_VISUAL_OUTPUT_DIR: suiteDirectory,
MD_TO_PDF_LAYOUT_VISUAL_SCOPE: suite.kind === "synthetic" ? "full" : "corpus",
MD_TO_PDF_R4_APPEND_INLINE_CODE_PROBES: suite.kind === "synthetic" ? "1" : "0"
};
if (suite.kind === "corpus") {
environment.MD_TO_PDF_CORPUS_ID = suite.id;
environment.MD_TO_PDF_R4_MARKDOWN_PATH = suite.markdownPath;
} else {
delete environment.MD_TO_PDF_CORPUS_ID;
delete environment.MD_TO_PDF_R4_MARKDOWN_PATH;
}
const startedAt = new Date().toISOString();
let record;
try {
const { stdout, stderr } = await execFileAsync(process.execPath, [matrixScript], {
cwd: repositoryDirectory,
windowsHide: true,
timeout: 24 * 60 * 60 * 1000,
maxBuffer: 256 * 1024 * 1024,
env: environment
});
fs.writeFileSync(path.join(logsDirectory, `${caseId}.stdout.log`), stdout, "utf8");
fs.writeFileSync(path.join(logsDirectory, `${caseId}.stderr.log`), stderr, "utf8");
const summary = parseChildSummary(stdout);
record = {
caseId,
fingerprint,
status: summary.gatePassed ? "passed" : "gate-failed",
startedAt,
finishedAt: new Date().toISOString(),
gateFailures: summary.gateFailures ?? [],
summaryPath: `summary-${caseId}.json`,
matrixPath: `matrix-${caseId}.html`
};
} catch (error) {
const stdout = Buffer.isBuffer(error?.stdout) ? error.stdout.toString("utf8") : String(error?.stdout || "");
const stderr = Buffer.isBuffer(error?.stderr) ? error.stderr.toString("utf8") : String(error?.stderr || "");
fs.writeFileSync(path.join(logsDirectory, `${caseId}.stdout.log`), stdout, "utf8");
fs.writeFileSync(path.join(logsDirectory, `${caseId}.stderr.log`), stderr || String(error?.stack || error), "utf8");
try {
const summary = parseChildSummary(stdout);
record = {
caseId,
fingerprint,
status: "gate-failed",
startedAt,
finishedAt: new Date().toISOString(),
gateFailures: summary.gateFailures ?? [String(error?.message || error)],
summaryPath: `summary-${caseId}.json`,
matrixPath: `matrix-${caseId}.html`
};
} catch {
record = {
caseId,
fingerprint,
status: "error",
startedAt,
finishedAt: new Date().toISOString(),
error: String(error?.message || error)
};
}
}
records[caseId] = record;
writeSuiteReports({ suiteDirectory, suite, fingerprint, records });
writeTopLevelIndex(suites);
}
const aggregate = writeSuiteReports({ suiteDirectory, suite, fingerprint, records });
writeTopLevelIndex(suites);
writeJsonAtomic(runPath, {
schemaVersion: DOCX_RELEASE_GATE_SCHEMA_VERSION,
pid: process.pid,
processStartedAt: runStartedAt,
completedAt: new Date().toISOString(),
command: process.argv,
suiteId: suite.id,
suiteFingerprint: fingerprint,
status: aggregate.gatePassed ? "passed" : "completed-with-failures",
execution: aggregate.execution
});
process.stdout.write(`${JSON.stringify(aggregate, null, 2)}\n`);
if (!selectedCaseId && !aggregate.gatePassed) {
process.exitCode = 1;
}