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
+1 -1
View File
@@ -11,7 +11,7 @@ const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const bundlePath = join(repositoryRoot, "font-packs", "bundle.json");
const bundle = JSON.parse(await readFile(bundlePath, "utf8"));
const outputRoot = join(repositoryRoot, "output", "font-packs", "root");
const appVersion = process.env.npm_package_version || "0.6.1";
const appVersion = process.env.npm_package_version || "0.6.2";
if (
bundle.schemaVersion !== 1 ||
@@ -71,6 +71,29 @@ function hasIndependentCover(manifest) {
return profiles.includes("project-report") || profiles.includes("tender");
}
export function resolveEffectiveCoverPageCount({
caseDefinition,
scope,
sourceMetadata
}) {
assert(caseDefinition && Number.isInteger(caseDefinition.coverPageCount),
"矩阵场景缺少有效封面页数");
if (scope !== "corpus") {
return caseDefinition.coverPageCount;
}
const profile = sourceMetadata?.document?.profile;
return profile === "project-report" || profile === "tender" ? 1 : 0;
}
export function isCorpusTextColorEquivalent(metrics) {
return Boolean(
metrics &&
metrics.inkIou >= 0.98 &&
metrics.edgeIou >= 0.98 &&
metrics.meanAbsoluteError <= 8
);
}
export function readBundledThemeMatrixDefinitions(themesDirectory) {
assert(
typeof themesDirectory === "string" && themesDirectory.length > 0,
@@ -8,7 +8,9 @@ import {
DOCX_LAYOUT_MATRIX_MARGIN_SCENARIOS,
DOCX_LAYOUT_MATRIX_ORIENTATIONS,
getDocxFontGateFailures,
isCorpusTextColorEquivalent,
readBundledThemeMatrixDefinitions,
resolveEffectiveCoverPageCount,
summarizeDocxLayoutVisualMatrix
} from "./docx-layout-visual-matrix-cases.mjs";
@@ -156,3 +158,40 @@ test("四套独立封面主题扩展为 40 个严格封面场景", () => {
]
);
});
test("真实语料按文档语义而不是主题能力判定独立封面", () => {
const caseDefinition = { coverPageCount: 1 };
assert.equal(resolveEffectiveCoverPageCount({
caseDefinition,
scope: "full",
sourceMetadata: {}
}), 1);
assert.equal(resolveEffectiveCoverPageCount({
caseDefinition,
scope: "corpus",
sourceMetadata: {}
}), 0);
assert.equal(resolveEffectiveCoverPageCount({
caseDefinition,
scope: "corpus",
sourceMetadata: { document: { profile: "tender" } }
}), 1);
});
test("短文本仅在墨迹边缘等价且像素误差低时忽略抗锯齿色差", () => {
assert.equal(isCorpusTextColorEquivalent({
inkIou: 1,
edgeIou: 1,
meanAbsoluteError: 6.9
}), true);
assert.equal(isCorpusTextColorEquivalent({
inkIou: 1,
edgeIou: 0.999,
meanAbsoluteError: 23
}), false);
assert.equal(isCorpusTextColorEquivalent({
inkIou: 0.8,
edgeIou: 1,
meanAbsoluteError: 4
}), false);
});
+100
View File
@@ -0,0 +1,100 @@
import fs from "node:fs";
import path from "node:path";
export const DOCX_REAL_WORLD_CORPUS = Object.freeze([
{
id: "changqing-tender-requirements",
label: "长庆油田智能健康技术服务招标技术要求",
markdownPath: "tmp/长庆油田智能健康技术服务招标技术要求.md",
requiredFeatures: ["inline-code", "table-math-alignment"]
},
{
id: "health-data-schema",
label: "运动健康类和营养饮食数据结构升级",
markdownPath: "tmp/运动健康类和营养饮食数据数据结构升级.md",
requiredFeatures: ["json-code-indentation"],
codeIndentationProbe: {
rootLine: "{",
nestedLineIncludes: '"heart_rate_daily"',
minimumIndentPt: 4
}
},
{
id: "m4n-table-design",
label: "M4N 全量表设计模块化讲解",
markdownPath: "tmp/数据中台项目周报_2026_W30_附件_M4N全量表设计模块化讲解.md",
requiredFeatures: ["mermaid", "long-document"]
}
]);
export function resolveDocxRealWorldCorpus(repositoryDirectory) {
return DOCX_REAL_WORLD_CORPUS.map((entry) => ({
...entry,
absoluteMarkdownPath: path.resolve(
repositoryDirectory,
entry.markdownPath
),
byteLength: fs.existsSync(path.resolve(repositoryDirectory, entry.markdownPath))
? fs.statSync(path.resolve(repositoryDirectory, entry.markdownPath)).size
: 0
}));
}
export function sortDocxRealWorldCorpusBySize(repositoryDirectory) {
return resolveDocxRealWorldCorpus(repositoryDirectory).sort(
(first, second) =>
first.byteLength - second.byteLength ||
first.id.localeCompare(second.id, "en")
);
}
export function validateDocxRealWorldCorpus(repositoryDirectory) {
const corpus = sortDocxRealWorldCorpusBySize(repositoryDirectory);
const failures = [];
for (const entry of corpus) {
if (!fs.existsSync(entry.absoluteMarkdownPath)) {
failures.push(`${entry.id} 缺少 Markdown${entry.markdownPath}`);
continue;
}
const source = fs.readFileSync(entry.absoluteMarkdownPath, "utf8");
if (!source.trim()) {
failures.push(`${entry.id} Markdown 为空`);
}
if (
entry.requiredFeatures.includes("json-code-indentation") &&
!/```json\s*[\s\S]*?\n[\t ]+\S/gu.test(source)
) {
failures.push(`${entry.id} 缺少带缩进的 JSON 围栏探针`);
}
if (
entry.requiredFeatures.includes("inline-code") &&
!/(^|[^`])`[^`\n]+`([^`]|$)/mu.test(source)
) {
failures.push(`${entry.id} 缺少行内代码探针`);
}
if (
entry.requiredFeatures.includes("table-math-alignment") &&
!/^\|.*\$[^$]+\$.*\|/mu.test(source)
) {
failures.push(`${entry.id} 缺少表格公式对齐探针`);
}
}
return { corpus, failures };
}
export function summarizeDocxReleaseGateMatrix({
baselineCaseCount,
corpusCaseCounts
}) {
const corpusCaseCount = Object.values(corpusCaseCounts).reduce(
(sum, value) => sum + value,
0
);
return {
baselineCaseCount,
corpusDocumentCount: Object.keys(corpusCaseCounts).length,
corpusCaseCounts,
corpusCaseCount,
totalCaseCount: baselineCaseCount + corpusCaseCount
};
}
+60
View File
@@ -0,0 +1,60 @@
import assert from "node:assert/strict";
import path from "node:path";
import test from "node:test";
import {
DOCX_REAL_WORLD_CORPUS,
sortDocxRealWorldCorpusBySize,
summarizeDocxReleaseGateMatrix,
validateDocxRealWorldCorpus
} from "./docx-real-world-corpus.mjs";
const repositoryDirectory = path.resolve(import.meta.dirname, "..");
test("冻结三份真实 Markdown 语料及必需特征", () => {
const validation = validateDocxRealWorldCorpus(repositoryDirectory);
assert.deepEqual(validation.failures, []);
assert.equal(validation.corpus.length, 3);
assert.equal(new Set(DOCX_REAL_WORLD_CORPUS.map((entry) => entry.id)).size, 3);
assert.ok(
DOCX_REAL_WORLD_CORPUS.some((entry) =>
entry.requiredFeatures.includes("json-code-indentation")
)
);
assert.ok(
DOCX_REAL_WORLD_CORPUS.some((entry) =>
entry.requiredFeatures.includes("table-math-alignment")
)
);
});
test("真实 Markdown 按文件字节数稳定升序执行", () => {
const corpus = sortDocxRealWorldCorpusBySize(repositoryDirectory);
assert.deepEqual(
corpus.map((entry) => entry.byteLength),
corpus.map((entry) => entry.byteLength).toSorted((a, b) => a - b)
);
assert.ok(corpus.every((entry) => entry.byteLength > 0));
});
test("发布门禁矩阵聚合为 140+420=560", () => {
assert.deepEqual(
summarizeDocxReleaseGateMatrix({
baselineCaseCount: 140,
corpusCaseCounts: Object.fromEntries(
DOCX_REAL_WORLD_CORPUS.map((entry) => [entry.id, 140])
)
}),
{
baselineCaseCount: 140,
corpusDocumentCount: 3,
corpusCaseCounts: {
"changqing-tender-requirements": 140,
"health-data-schema": 140,
"m4n-table-design": 140
},
corpusCaseCount: 420,
totalCaseCount: 560
}
);
});
+232
View File
@@ -0,0 +1,232 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import {
createDocxLayoutVisualMatrixCases,
readBundledThemeMatrixDefinitions
} from "./docx-layout-visual-matrix-cases.mjs";
import { validateDocxRealWorldCorpus } from "./docx-real-world-corpus.mjs";
export const DOCX_RELEASE_GATE_CASE_COUNT = 140;
export const DOCX_RELEASE_GATE_SCHEMA_VERSION = 2;
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
export function createDocxReleaseGateSuites(repositoryDirectory) {
const validation = validateDocxRealWorldCorpus(repositoryDirectory);
assert(
validation.failures.length === 0,
`真实语料无效:${validation.failures.join("")}`
);
const themes = readBundledThemeMatrixDefinitions(
path.join(repositoryDirectory, "themes")
);
const cases = createDocxLayoutVisualMatrixCases(themes);
assert(
cases.length === DOCX_RELEASE_GATE_CASE_COUNT,
`单套矩阵应为 ${DOCX_RELEASE_GATE_CASE_COUNT},实际 ${cases.length}`
);
const caseIds = cases.map((entry) => entry.id);
return [
{
id: "synthetic-baseline-140",
label: "标准合成基线 140",
kind: "synthetic",
order: 0,
outputDirectoryName: "synthetic-baseline-140",
expectedCaseCount: caseIds.length,
caseIds
},
...validation.corpus.map((entry, index) => ({
id: entry.id,
label: `${entry.label}${entry.byteLength} bytes`,
kind: "corpus",
order: index + 1,
outputDirectoryName:
`corpus-${String(index + 1).padStart(2, "0")}-${entry.byteLength}-${entry.id}`,
expectedCaseCount: caseIds.length,
caseIds,
markdownPath: entry.markdownPath,
absoluteMarkdownPath: entry.absoluteMarkdownPath,
markdownByteLength: entry.byteLength,
requiredFeatures: entry.requiredFeatures
}))
];
}
export function isSuiteAggregatePassed(aggregate, suite) {
return Boolean(
aggregate &&
aggregate.suite?.id === suite.id &&
aggregate.execution?.expectedCaseCount === suite.expectedCaseCount &&
aggregate.execution?.completedCaseCount === suite.expectedCaseCount &&
aggregate.execution?.passedCaseCount === suite.expectedCaseCount &&
aggregate.gatePassed === true
);
}
export function resolveDocxReleaseGateSuite({
suites,
requestedId,
aggregates,
allowOutOfOrder = false
}) {
assert(Array.isArray(suites) && suites.length === 4, "发布门禁必须包含四套独立矩阵");
const target = !requestedId || requestedId === "next"
? suites.find((suite) => !isSuiteAggregatePassed(aggregates.get(suite.id), suite))
: suites.find((suite) => suite.id === requestedId);
if (!target) {
if (!requestedId || requestedId === "next") {
return undefined;
}
throw new Error(`未知发布门禁套件:${requestedId}`);
}
if (!allowOutOfOrder || !requestedId || requestedId === "next") {
const unmetPrerequisite = suites
.slice(0, target.order)
.find((suite) => !isSuiteAggregatePassed(aggregates.get(suite.id), suite));
assert(
!unmetPrerequisite,
`必须先通过前置套件 ${unmetPrerequisite?.id}`
);
}
return target;
}
function collectFingerprintFiles(repositoryDirectory, suite) {
const roots = [
"themes",
"apps/server/scripts",
"apps/server/src",
"packages/application/src",
"packages/core/src",
"packages/document-visual-diff/src",
"packages/docx-engine/assets",
"packages/docx-engine/src",
"packages/preview-engine/src",
"packages/renderer/src"
];
const files = [
"package-lock.json",
"scripts/docx-layout-visual-matrix-cases.mjs",
"scripts/docx-real-world-corpus.mjs",
"scripts/docx-release-gate-suites.mjs",
"scripts/verify-docx-layout-visual-matrix.mjs",
"scripts/verify-docx-release-gate-suite.mjs"
];
const allowedExtension = /\.(?:css|js|json|lua|mjs|ts)$/iu;
const visit = (relativeDirectory) => {
const absoluteDirectory = path.join(repositoryDirectory, relativeDirectory);
if (!fs.existsSync(absoluteDirectory)) {
return;
}
for (const entry of fs.readdirSync(absoluteDirectory, { withFileTypes: true })) {
const relativePath = path.join(relativeDirectory, entry.name);
if (entry.isDirectory()) {
visit(relativePath);
} else if (allowedExtension.test(entry.name)) {
files.push(relativePath);
}
}
};
for (const root of roots) {
visit(root);
}
if (suite.markdownPath) {
files.push(suite.markdownPath);
}
return [...new Set(files)].sort((left, right) => left.localeCompare(right, "en"));
}
export function createDocxReleaseGateFingerprint(repositoryDirectory, suite) {
const hash = crypto.createHash("sha256");
hash.update(`schema:${DOCX_RELEASE_GATE_SCHEMA_VERSION}\n`);
hash.update(`suite:${suite.id}\n`);
for (const relativePath of collectFingerprintFiles(repositoryDirectory, suite)) {
const absolutePath = path.join(repositoryDirectory, relativePath);
if (!fs.existsSync(absolutePath)) {
hash.update(`${relativePath}:missing\n`);
continue;
}
hash.update(`${relativePath}\0`);
hash.update(fs.readFileSync(absolutePath));
hash.update("\0");
}
return hash.digest("hex");
}
export function readJsonIfExists(filePath) {
if (!fs.existsSync(filePath)) {
return undefined;
}
try {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
} catch {
return undefined;
}
}
const WINDOWS_RENAME_RETRY_CODES = new Set(["EACCES", "EBUSY", "EPERM"]);
function sleepSync(milliseconds) {
Atomics.wait(
new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT)),
0,
0,
milliseconds
);
}
export function writeJsonAtomic(filePath, value, options = {}) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const temporaryPath = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
fs.writeFileSync(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
const rename = options.rename ?? fs.renameSync;
const sleep = options.sleep ?? sleepSync;
const retryDeadline = Date.now() + (options.retryWindowMs ?? 3_000);
let attempt = 0;
while (true) {
try {
rename(temporaryPath, filePath);
return;
} catch (error) {
if (
!WINDOWS_RENAME_RETRY_CODES.has(error?.code) ||
Date.now() >= retryDeadline
) {
fs.rmSync(temporaryPath, { force: true });
throw error;
}
attempt += 1;
sleep(Math.min(100, 10 * attempt));
}
}
}
export function summarizeSuiteProgress({ suite, fingerprint, records }) {
const currentRecords = suite.caseIds
.map((caseId) => records[caseId])
.filter((record) => record?.fingerprint === fingerprint);
const passedCaseCount = currentRecords.filter((record) => record.status === "passed").length;
const gateFailedCaseCount = currentRecords.filter((record) => record.status === "gate-failed").length;
const errorCaseCount = currentRecords.filter((record) => record.status === "error").length;
const completedCaseCount = currentRecords.length;
return {
expectedCaseCount: suite.expectedCaseCount,
completedCaseCount,
pendingCaseCount: suite.expectedCaseCount - completedCaseCount,
passedCaseCount,
gateFailedCaseCount,
errorCaseCount,
complete: completedCaseCount === suite.expectedCaseCount,
gatePassed:
passedCaseCount === suite.expectedCaseCount &&
gateFailedCaseCount === 0 &&
errorCaseCount === 0
};
}
+135
View File
@@ -0,0 +1,135 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import {
createDocxReleaseGateSuites,
isSuiteAggregatePassed,
resolveDocxReleaseGateSuite,
summarizeSuiteProgress,
writeJsonAtomic
} from "./docx-release-gate-suites.mjs";
const repositoryDirectory = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
".."
);
function passedAggregate(suite) {
return {
suite: { id: suite.id },
execution: {
expectedCaseCount: 140,
completedCaseCount: 140,
passedCaseCount: 140
},
gatePassed: true
};
}
test("发布门禁拆分为四套独立 140 且真实文档按字节升序", () => {
const suites = createDocxReleaseGateSuites(repositoryDirectory);
assert.equal(suites.length, 4);
assert.equal(suites[0].id, "synthetic-baseline-140");
assert.ok(suites.every((suite) => suite.expectedCaseCount === 140));
assert.deepEqual(
suites.slice(1).map((suite) => suite.markdownByteLength),
suites.slice(1).map((suite) => suite.markdownByteLength).toSorted((a, b) => a - b)
);
assert.ok(suites.slice(1).every((suite) => suite.outputDirectoryName.includes(String(suite.markdownByteLength))));
});
test("next 严格遵守合成基线和真实文档大小顺序", () => {
const suites = createDocxReleaseGateSuites(repositoryDirectory);
const aggregates = new Map();
assert.equal(resolveDocxReleaseGateSuite({ suites, requestedId: "next", aggregates }).id, suites[0].id);
aggregates.set(suites[0].id, passedAggregate(suites[0]));
assert.equal(resolveDocxReleaseGateSuite({ suites, requestedId: "next", aggregates }).id, suites[1].id);
assert.throws(
() => resolveDocxReleaseGateSuite({ suites, requestedId: suites[2].id, aggregates }),
/必须先通过前置套件/u
);
for (const suite of suites) {
aggregates.set(suite.id, passedAggregate(suite));
}
assert.equal(resolveDocxReleaseGateSuite({ suites, requestedId: "next", aggregates }), undefined);
});
test("只有显式指定套件时才允许受控越过前置门禁", () => {
const suites = createDocxReleaseGateSuites(repositoryDirectory);
const aggregates = new Map();
assert.equal(
resolveDocxReleaseGateSuite({
suites,
requestedId: suites[3].id,
aggregates,
allowOutOfOrder: true
}).id,
suites[3].id
);
assert.equal(
resolveDocxReleaseGateSuite({
suites,
requestedId: "next",
aggregates,
allowOutOfOrder: true
}).id,
suites[0].id
);
});
test("套件进度独立统计通过、门禁失败和基础设施错误", () => {
const suite = createDocxReleaseGateSuites(repositoryDirectory)[0];
const records = {
[suite.caseIds[0]]: { fingerprint: "same", status: "passed" },
[suite.caseIds[1]]: { fingerprint: "same", status: "gate-failed" },
[suite.caseIds[2]]: { fingerprint: "same", status: "error" },
[suite.caseIds[3]]: { fingerprint: "old", status: "passed" }
};
const progress = summarizeSuiteProgress({ suite, fingerprint: "same", records });
assert.deepEqual(progress, {
expectedCaseCount: 140,
completedCaseCount: 3,
pendingCaseCount: 137,
passedCaseCount: 1,
gateFailedCaseCount: 1,
errorCaseCount: 1,
complete: false,
gatePassed: false
});
assert.equal(isSuiteAggregatePassed({ suite: { id: suite.id }, execution: progress, gatePassed: false }, suite), false);
});
test("Windows 瞬时文件锁不会中断原子进度写入", () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "mdtp-gate-atomic-"));
const filePath = path.join(directory, "progress.json");
let attempts = 0;
try {
writeJsonAtomic(filePath, { completed: 9 }, {
rename(source, destination) {
attempts += 1;
if (attempts < 3) {
const error = new Error("temporary Windows lock");
error.code = "EPERM";
throw error;
}
fs.renameSync(source, destination);
},
sleep() {}
});
assert.equal(attempts, 3);
assert.deepEqual(
JSON.parse(fs.readFileSync(filePath, "utf8")),
{ completed: 9 }
);
assert.deepEqual(
fs.readdirSync(directory).filter((name) => name.endsWith(".tmp")),
[]
);
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
});
+453 -20
View File
@@ -4,6 +4,7 @@ import path from "node:path";
import { promisify } from "node:util";
import { DOMParser } from "@xmldom/xmldom";
import { unzipSync } from "fflate";
import matter from "gray-matter";
import {
createPdfDocumentSnapshot,
@@ -11,6 +12,8 @@ import {
createWordPdfAdapter,
createWpsPdfAdapter,
getBlockingVisualDiffIssues,
isCrossEngineRasterEquivalent,
normalizePdfEditableText,
renderPdfVisualDiffHtml,
serializePdfVisualDiffJson
} from "../packages/document-visual-diff/dist/index.js";
@@ -21,9 +24,14 @@ import {
import {
createDocxLayoutVisualMatrixCases,
getDocxFontGateFailures,
isCorpusTextColorEquivalent,
readBundledThemeMatrixDefinitions,
resolveEffectiveCoverPageCount,
summarizeDocxLayoutVisualMatrix
} from "./docx-layout-visual-matrix-cases.mjs";
import {
DOCX_REAL_WORLD_CORPUS
} from "./docx-real-world-corpus.mjs";
const execFileAsync = promisify(execFile);
const WORD_NAMESPACE =
@@ -32,10 +40,10 @@ const MATH_NAMESPACE =
"http://schemas.openxmlformats.org/officeDocument/2006/math";
const decoder = new TextDecoder();
const repositoryDirectory = path.resolve(import.meta.dirname, "..");
const outputDirectory = path.join(
const outputDirectory = path.resolve(
repositoryDirectory,
"output",
"docx-layout-visual-matrix"
process.env.MD_TO_PDF_LAYOUT_VISUAL_OUTPUT_DIR?.trim() ||
path.join("output", "docx-layout-visual-matrix")
);
const matrixScript = path.join(
repositoryDirectory,
@@ -64,6 +72,23 @@ const selectedOrientation =
process.env.MD_TO_PDF_LAYOUT_VISUAL_ORIENTATION?.trim() || undefined;
const selectedMarginScenarioId =
process.env.MD_TO_PDF_LAYOUT_VISUAL_MARGIN?.trim() || undefined;
const expectInlineCodeProbes =
process.env.MD_TO_PDF_R4_APPEND_INLINE_CODE_PROBES?.trim() !== "0";
const selectedCorpusId =
process.env.MD_TO_PDF_CORPUS_ID?.trim() || undefined;
const corpusDefinition = selectedCorpusId
? DOCX_REAL_WORLD_CORPUS.find((entry) => entry.id === selectedCorpusId)
: undefined;
const corpusSourceMetadata = selectedScope === "corpus" &&
process.env.MD_TO_PDF_R4_MARKDOWN_PATH?.trim()
? matter(fs.readFileSync(
path.resolve(
repositoryDirectory,
process.env.MD_TO_PDF_R4_MARKDOWN_PATH.trim()
),
"utf8"
)).data
: undefined;
const scopedCases = selectedCaseId
? cases.filter((entry) => entry.id === selectedCaseId)
: selectedScope === "cover"
@@ -185,6 +210,43 @@ function paragraphBlockKind(paragraph, styleId) {
return "paragraph";
}
function directWordChildren(node, localName) {
return Array.from(node?.childNodes ?? []).filter(
(child) =>
child.nodeType === 1 &&
child.localName === localName &&
child.namespaceURI === WORD_NAMESPACE
);
}
function nearestWordAncestor(node, localName) {
let current = node?.parentNode;
while (current) {
if (
current.localName === localName &&
current.namespaceURI === WORD_NAMESPACE
) {
return current;
}
current = current.parentNode;
}
return undefined;
}
function paragraphTableCoordinates(paragraph, tableIndexes) {
const cell = nearestWordAncestor(paragraph, "tc");
const row = nearestWordAncestor(paragraph, "tr");
const table = nearestWordAncestor(paragraph, "tbl");
if (!cell || !row || !table) {
return undefined;
}
return {
tableGroupId: `table-${tableIndexes.get(table)}`,
tableRowIndex: directWordChildren(table, "tr").indexOf(row),
tableColumnIndex: directWordChildren(row, "tc").indexOf(cell)
};
}
function extractEditableContract(docxPath) {
const entries = unzipSync(Uint8Array.from(fs.readFileSync(docxPath)));
const documentXml = entries["word/document.xml"];
@@ -193,13 +255,83 @@ function extractEditableContract(docxPath) {
decoder.decode(documentXml),
"application/xml"
);
const tableIndexes = new Map(
Array.from(document.getElementsByTagName("*")).filter(
(node) => node.localName === "tbl" && node.namespaceURI === WORD_NAMESPACE
).map((table, index) => [table, index])
);
let section = "cover";
let hasCoverSection = false;
const paragraphs = [];
let inlineCodeParagraphCount = 0;
let inlineCodeExactLineRuleCount = 0;
let pureMathTableParagraphCount = 0;
let stabilizedPureMathTableParagraphCount = 0;
for (const paragraph of Array.from(document.getElementsByTagName("*")).filter(
(node) => node.localName === "p" && node.namespaceURI === WORD_NAMESPACE
)) {
const descendants = Array.from(paragraph.getElementsByTagName("*"));
const inlineCode = descendants.some(
(node) =>
node.localName === "rStyle" &&
node.namespaceURI === WORD_NAMESPACE &&
(node.getAttribute("w:val") || node.getAttribute("val")) ===
"VerbatimChar"
);
if (inlineCode) {
inlineCodeParagraphCount += 1;
if (
descendants.some(
(node) =>
node.localName === "spacing" &&
node.namespaceURI === WORD_NAMESPACE &&
(node.getAttribute("w:lineRule") ||
node.getAttribute("lineRule")) === "exact"
)
) {
inlineCodeExactLineRuleCount += 1;
}
}
if (hasAncestor(paragraph, "tc")) {
const directContent = Array.from(paragraph.childNodes).filter(
(node) =>
node.nodeType === 1 &&
!(
node.localName === "pPr" &&
node.namespaceURI === WORD_NAMESPACE
)
);
const directMath = directContent.filter(
(node) =>
node.localName === "oMath" &&
node.namespaceURI === MATH_NAMESPACE
);
const stabilizers = directContent.filter(
(node) =>
node.localName === "r" &&
node.namespaceURI === WORD_NAMESPACE &&
Array.from(node.getElementsByTagName("*")).some(
(descendant) =>
descendant.localName === "noProof" &&
descendant.namespaceURI === WORD_NAMESPACE
) &&
Array.from(node.getElementsByTagName("*")).some(
(descendant) =>
descendant.localName === "t" &&
descendant.namespaceURI === WORD_NAMESPACE &&
descendant.textContent === "\u200B"
)
);
if (
directMath.length > 0 &&
directContent.length === directMath.length + stabilizers.length
) {
pureMathTableParagraphCount += 1;
if (stabilizers.length > 0) {
stabilizedPureMathTableParagraphCount += 1;
}
}
}
const hasSectionBreak = descendants.some(
(node) =>
node.localName === "sectPr" && node.namespaceURI === WORD_NAMESPACE
@@ -209,20 +341,53 @@ function extractEditableContract(docxPath) {
section = "body";
continue;
}
const text = descendants.flatMap((node) =>
node.localName === "t" &&
(node.namespaceURI === WORD_NAMESPACE ||
node.namespaceURI === MATH_NAMESPACE)
? [node.textContent ?? ""]
: []
).join("");
const hardBreakSegments = [""];
const mathCharacterIndexes = [];
let normalizedCharacterOffset = 0;
for (const node of descendants) {
if (
node.localName === "t" &&
(node.namespaceURI === WORD_NAMESPACE ||
node.namespaceURI === MATH_NAMESPACE)
) {
const nodeText = (node.textContent ?? "").replace(
/[\u200B\u2060\uFEFF]/gu,
""
);
hardBreakSegments[hardBreakSegments.length - 1] += nodeText;
const normalizedNodeLength = Array.from(
normalizePdfEditableText(nodeText)
).length;
if (node.namespaceURI === MATH_NAMESPACE) {
for (let index = 0; index < normalizedNodeLength; index += 1) {
mathCharacterIndexes.push(normalizedCharacterOffset + index);
}
}
normalizedCharacterOffset += normalizedNodeLength;
} else if (
node.localName === "br" &&
node.namespaceURI === WORD_NAMESPACE
) {
hardBreakSegments.push("");
}
}
const text = hardBreakSegments.join("");
if (!text || isInternalLayoutSpacerParagraph(descendants, text)) {
continue;
}
const styleId = paragraphStyleId(paragraph);
const tableCoordinates = paragraphTableCoordinates(paragraph, tableIndexes);
paragraphs.push({
index: paragraphs.length,
text,
...(mathCharacterIndexes.length > 0
? { mathCharacterIndexes }
: {}),
...(hardBreakSegments.length > 1
? { hardBreakSegments }
: {}),
...(inlineCode ? { hasInlineCode: true } : {}),
...(tableCoordinates ?? {}),
...(styleId ? { styleId } : {}),
role: paragraphRole(styleId),
blockKind: paragraphBlockKind(paragraph, styleId),
@@ -236,7 +401,13 @@ function extractEditableContract(docxPath) {
}
return {
text: paragraphs.map((paragraph) => paragraph.text).join(""),
paragraphs
paragraphs,
translationInvariants: {
inlineCodeParagraphCount,
inlineCodeExactLineRuleCount,
pureMathTableParagraphCount,
stabilizedPureMathTableParagraphCount
}
};
}
@@ -338,6 +509,82 @@ function inspectInlineCodeContinuity(snapshot, label) {
};
}
function inspectCodeIndentation(snapshot, label, probe) {
if (!probe) {
return {
label,
applicable: false,
passed: true,
failures: []
};
}
for (const page of snapshot.pages) {
const nestedIndex = page.lines.findIndex((line) =>
line.text.includes(probe.nestedLineIncludes)
);
if (nestedIndex < 0) {
continue;
}
const nested = page.lines[nestedIndex];
const root = page.lines
.slice(0, nestedIndex)
.reverse()
.find(
(line) =>
line.text.trim() === probe.rootLine &&
nested.baselineY - line.baselineY <= 30
);
if (!root) {
continue;
}
const indentPt = nested.bounds.x - root.bounds.x;
const passed = indentPt >= probe.minimumIndentPt;
return {
label,
applicable: true,
passed,
pageNumber: page.pageNumber,
rootText: root.text,
nestedText: nested.text,
rootXPt: root.bounds.x,
nestedXPt: nested.bounds.x,
indentPt,
failures: passed
? []
: [
`${label}: CODE_INDENTATION_MISMATCH - JSON 嵌套行缩进 ${indentPt.toFixed(2)}pt,小于 ${probe.minimumIndentPt}pt`
]
};
}
return {
label,
applicable: true,
passed: false,
failures: [
`${label}: CODE_INDENTATION_PROBE_UNAVAILABLE - 未定位 JSON 根行与嵌套行`
]
};
}
function docxTranslationInvariantFailures(invariants) {
const failures = [];
if (invariants.inlineCodeExactLineRuleCount > 0) {
failures.push(
`DOCX: INLINE_CODE_EXACT_LINE_BOX - ${invariants.inlineCodeExactLineRuleCount} 个行内代码段落仍使用 exact 行盒`
);
}
if (
invariants.pureMathTableParagraphCount !==
invariants.stabilizedPureMathTableParagraphCount
) {
failures.push(
"DOCX: TABLE_MATH_ALIGNMENT_UNSTABLE - " +
`${invariants.pureMathTableParagraphCount - invariants.stabilizedPureMathTableParagraphCount} 个纯公式表格段落缺少对齐稳定结构`
);
}
return failures;
}
function buildPageSemantics(pageCount, coverPageCount, config) {
assert(
pageCount > coverPageCount,
@@ -423,6 +670,108 @@ function reportCoverGateFailures(report, label) {
.map((issue) => `${label}: ${issue.code} - ${issue.message}`);
}
function metricRatio(first, second) {
const maximum = Math.max(first, second);
return maximum > 0 ? Math.min(first, second) / maximum : 1;
}
const MAXIMUM_CORPUS_ELEMENT_COLOR_DELTA = 55;
function reportCorpusGateFailures(
report,
label,
coverPageCount
) {
const failures = [];
if ((report.basic.candidateEditableSimilarity ?? 0) < 0.995) {
failures.push(
`${label}: EDITABLE_CONTENT_SIMILARITY - Office 可编辑正文相似度 ` +
`${((report.basic.candidateEditableSimilarity ?? 0) * 100).toFixed(3)}% 低于 99.5%`
);
}
const strictKinds = new Set([
"heading",
"title",
"caption",
"table-header",
"code-block"
]);
for (const comparison of report.basic.semanticBlockVisuals ?? []) {
const index = comparison.expectation.index + 1;
const visualUnavailable =
comparison.status === "unavailable" ||
comparison.lines.length === 0 ||
comparison.issues?.some(
(issue) => issue.code === "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE"
);
if (visualUnavailable) {
failures.push(
`${label}: SEMANTIC_BLOCK_VISUAL_UNAVAILABLE - 第 ${index} 个元素块无法建立视觉观测`
);
continue;
}
for (const line of comparison.lines) {
const metrics = line.metrics;
if (!metrics) {
continue;
}
const heightRatio = metricRatio(
metrics.baselineHeightPx,
metrics.candidateHeightPx
);
if (heightRatio < 0.78) {
failures.push(
`${label}: ELEMENT_LINE_BOX_MISMATCH - 第 ${index} 个元素块第 ${line.lineIndex + 1} 行高度比例 ${heightRatio.toFixed(3)} 低于 0.78`
);
}
const widthRatio = metricRatio(
metrics.baselineWidthPx,
metrics.candidateWidthPx
);
if (comparison.lines.length === 1 && widthRatio < 0.85) {
failures.push(
`${label}: ELEMENT_WIDTH_MISMATCH - 第 ${index} 个单行元素块宽度比例 ${widthRatio.toFixed(3)} 低于 0.85`
);
}
const minimumIou = strictKinds.has(
comparison.expectation.blockKind
) ? 0.45 : 0.3;
if (
comparison.lines.length === 1 &&
metrics.inkIou < minimumIou &&
metrics.edgeIou < minimumIou
) {
failures.push(
`${label}: ELEMENT_RASTER_MISMATCH - 第 ${index} 个元素块第 ${line.lineIndex + 1} 行墨迹与边缘 IoU 均低于 ${minimumIou}`
);
}
const textReflow = line.issues?.some(
(issue) => issue.details?.textReflow === true
);
const crossEngineRasterEquivalent = isCrossEngineRasterEquivalent(
metrics,
textReflow,
comparison.expectation.blockKind,
comparison.expectation.hasInlineCode === true
);
if (!textReflow &&
!crossEngineRasterEquivalent &&
!isCorpusTextColorEquivalent(metrics) && (
metrics.backgroundColorDelta > MAXIMUM_CORPUS_ELEMENT_COLOR_DELTA ||
metrics.foregroundColorDelta > MAXIMUM_CORPUS_ELEMENT_COLOR_DELTA
)) {
failures.push(
`${label}: ELEMENT_COLOR_MISMATCH - 第 ${index} 个元素块第 ${line.lineIndex + 1} 行颜色差异超限`
);
}
}
}
if (coverPageCount > 0) {
failures.push(...reportCoverGateFailures(report, label));
}
return [...new Set(failures)];
}
function reportSummary(report) {
const paragraphIssues = report.basic.paragraphLayouts?.flatMap(
(paragraph) => paragraph.issues
@@ -599,9 +948,18 @@ assert(
`视觉矩阵用例不存在:${selectedCaseId}`
);
assert(
selectedScope === "full" || selectedScope === "cover",
["full", "cover", "corpus"].includes(selectedScope),
`不支持的视觉矩阵作用域:${selectedScope}`
);
assert(
selectedScope !== "corpus" || corpusDefinition,
`真实语料门禁缺少有效 MD_TO_PDF_CORPUS_ID${selectedCorpusId}`
);
assert(
selectedScope !== "corpus" ||
Boolean(process.env.MD_TO_PDF_R4_MARKDOWN_PATH?.trim()),
"真实语料门禁必须显式提供 MD_TO_PDF_R4_MARKDOWN_PATH"
);
assert(
!selectedOrientation || ["portrait", "landscape"].includes(selectedOrientation),
`不支持的视觉矩阵方向:${selectedOrientation}`
@@ -632,6 +990,11 @@ for (const caseDefinition of selectedCases) {
);
const docxPath = path.resolve(repositoryDirectory, result.docx.outputFile);
const editable = extractEditableContract(docxPath);
const effectiveCoverPageCount = resolveEffectiveCoverPageCount({
caseDefinition,
scope: selectedScope,
sourceMetadata: corpusSourceMetadata
});
const wordGeneration = await wordAdapter.generate({ docxPath });
const wpsGeneration = await wpsAdapter.generate({ docxPath });
const wordPdfPath = path.join(caseDirectory, "word.pdf");
@@ -668,17 +1031,17 @@ for (const caseDefinition of selectedCases) {
});
const chromiumPageSemantics = buildPageSemantics(
chromium.pageCount,
caseDefinition.coverPageCount,
effectiveCoverPageCount,
result.exportConfig
);
const wordPageSemantics = buildPageSemantics(
word.pageCount,
caseDefinition.coverPageCount,
effectiveCoverPageCount,
result.exportConfig
);
const wpsPageSemantics = buildPageSemantics(
wps.pageCount,
caseDefinition.coverPageCount,
effectiveCoverPageCount,
result.exportConfig
);
const reportOptions = {
@@ -715,6 +1078,23 @@ for (const caseDefinition of selectedCases) {
word: inspectInlineCodeContinuity(word, "Microsoft Word"),
wps: inspectInlineCodeContinuity(wps, "WPS Writer")
};
const codeIndentation = {
chromium: inspectCodeIndentation(
chromium,
"Chromium",
corpusDefinition?.codeIndentationProbe
),
word: inspectCodeIndentation(
word,
"Microsoft Word",
corpusDefinition?.codeIndentationProbe
),
wps: inspectCodeIndentation(
wps,
"WPS Writer",
corpusDefinition?.codeIndentationProbe
)
};
const rasterDirectory = path.join(caseDirectory, "raster-pages");
fs.rmSync(rasterDirectory, { recursive: true, force: true });
@@ -742,9 +1122,13 @@ for (const caseDefinition of selectedCases) {
renderedFonts: wps.fonts,
engine: "wps"
}),
...inlineCodeContinuity.chromium.failures,
...inlineCodeContinuity.word.failures,
...inlineCodeContinuity.wps.failures,
...(expectInlineCodeProbes
? [
...inlineCodeContinuity.chromium.failures,
...inlineCodeContinuity.word.failures,
...inlineCodeContinuity.wps.failures
]
: []),
...reportGateFailures(wordReport, "Chromium/Word"),
...reportGateFailures(wpsReport, "Chromium/WPS"),
...reportGateFailures(officeReport, "Word/WPS")
@@ -755,7 +1139,46 @@ for (const caseDefinition of selectedCases) {
...reportCoverGateFailures(wpsReport, "Chromium/WPS"),
...reportCoverGateFailures(officeReport, "Word/WPS")
])]
: allCurrentFailures;
: selectedScope === "corpus"
? [...new Set([
...pageGeometryFailures(chromium, caseDefinition, "Chromium"),
...pageGeometryFailures(word, caseDefinition, "Microsoft Word"),
...pageGeometryFailures(wps, caseDefinition, "WPS Writer"),
...getDocxFontGateFailures({
theme: themeDefinitions.find((theme) => theme.id === caseDefinition.themeId),
inspection: result.docx.inspection,
renderedFonts: word.fonts,
engine: "word"
}),
...getDocxFontGateFailures({
theme: themeDefinitions.find((theme) => theme.id === caseDefinition.themeId),
inspection: result.docx.inspection,
renderedFonts: wps.fonts,
engine: "wps"
}),
...docxTranslationInvariantFailures(
editable.translationInvariants
),
...codeIndentation.chromium.failures,
...codeIndentation.word.failures,
...codeIndentation.wps.failures,
...reportCorpusGateFailures(
wordReport,
"Chromium/Word",
effectiveCoverPageCount
),
...reportCorpusGateFailures(
wpsReport,
"Chromium/WPS",
effectiveCoverPageCount
),
...reportCorpusGateFailures(
officeReport,
"Word/WPS",
effectiveCoverPageCount
)
])]
: allCurrentFailures;
gateFailures.push(
...currentFailures.map((failure) => `${caseDefinition.id}: ${failure}`)
);
@@ -772,7 +1195,7 @@ for (const caseDefinition of selectedCases) {
themeDefaultMargins: caseDefinition.themeDefaultMargins,
paper: caseDefinition.paper,
exportConfig: result.exportConfig,
coverPageCount: caseDefinition.coverPageCount,
coverPageCount: effectiveCoverPageCount,
pages: {
chromium: chromium.pageCount,
word: word.pageCount,
@@ -787,7 +1210,9 @@ for (const caseDefinition of selectedCases) {
wps: wps.fonts
},
editableParagraphCount: editable.paragraphs.length,
translationInvariants: editable.translationInvariants,
inlineCodeContinuity,
codeIndentation,
reports: {
word: reportSummary(wordReport),
wps: reportSummary(wpsReport),
@@ -811,6 +1236,14 @@ const summary = {
matrixDefinition,
selectedCaseId,
selectedScope,
corpus: corpusDefinition
? {
id: corpusDefinition.id,
label: corpusDefinition.label,
markdownPath: corpusDefinition.markdownPath,
requiredFeatures: corpusDefinition.requiredFeatures
}
: undefined,
selectedOrientation,
selectedMarginScenarioId,
execution: {
+4
View File
@@ -0,0 +1,4 @@
process.stderr.write(
"verify-docx-release-gate-560 已改为兼容入口:每次只执行下一套独立 140,不再启动整批 560。\n"
);
await import("./verify-docx-release-gate-suite.mjs");
+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;
}