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
+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
};
}