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:
@@ -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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user