{
rendererVersion: typeof RENDERER_VERSION;
+ markdownBody: string;
}
const markdownOptions: MarkdownItOptions = {
@@ -93,6 +95,104 @@ const markdown = new MarkdownIt(markdownOptions)
trust: false
});
+const tableBreakMarkupPattern = /^(?:
|
|
)/iu;
+
+function tableBreakCandidateRule(
+ state: StateInline,
+ silent: boolean
+): boolean {
+ const match = state.src.slice(state.pos).match(tableBreakMarkupPattern);
+ if (!match) {
+ return false;
+ }
+ if (!silent) {
+ const token = state.push("table_break_candidate", "", 0);
+ token.content = match[0];
+ }
+ state.pos += match[0].length;
+ return true;
+}
+
+markdown.inline.ruler.before(
+ "html_inline",
+ "table_break_candidate",
+ tableBreakCandidateRule
+);
+markdown.core.ruler.after("inline", "scope_table_breaks", (state) => {
+ let tableCellDepth = 0;
+ for (const token of state.tokens) {
+ if (token.type === "td_open" || token.type === "th_open") {
+ tableCellDepth += 1;
+ continue;
+ }
+ if (token.type === "td_close" || token.type === "th_close") {
+ tableCellDepth = Math.max(0, tableCellDepth - 1);
+ continue;
+ }
+ if (token.type !== "inline") {
+ continue;
+ }
+ for (const child of token.children ?? []) {
+ if (child.type === "table_break_candidate" && tableCellDepth > 0) {
+ child.type = "table_break";
+ }
+ }
+ }
+});
+markdown.renderer.rules.table_break_candidate = (tokens, index) =>
+ markdown.utils.escapeHtml(tokens[index]?.content ?? "");
+markdown.renderer.rules.table_break = () => "
";
+
+const markdownAlertTitles = new Map([
+ ["note", "Note"],
+ ["tip", "Tip"],
+ ["important", "Important"],
+ ["warning", "Warning"],
+ ["caution", "Caution"]
+]);
+
+markdown.core.ruler.after("scope_table_breaks", "github_alerts", (state) => {
+ for (let index = 0; index < state.tokens.length - 2; index += 1) {
+ const blockquote = state.tokens[index];
+ const paragraph = state.tokens[index + 1];
+ const inline = state.tokens[index + 2];
+ if (
+ blockquote?.type !== "blockquote_open" ||
+ paragraph?.type !== "paragraph_open" ||
+ inline?.type !== "inline"
+ ) {
+ continue;
+ }
+ const [marker, bodyBreak] = inline.children ?? [];
+ const match = marker?.type === "text"
+ ? marker.content.match(/^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]$/iu)
+ : undefined;
+ if (!marker || !match) {
+ continue;
+ }
+ const alertType = match[1]?.toLowerCase();
+ const title = alertType ? markdownAlertTitles.get(alertType) : undefined;
+ if (!alertType || !title) {
+ continue;
+ }
+ blockquote.attrJoin("class", `md-alert md-alert-${alertType}`);
+ marker.type = "markdown_alert_title";
+ marker.content = title;
+ marker.meta = { alertType };
+ if (bodyBreak?.type === "softbreak") {
+ bodyBreak.type = "markdown_alert_body_break";
+ }
+ }
+});
+markdown.renderer.rules.markdown_alert_title = (tokens, index) => {
+ const token = tokens[index];
+ const alertType = typeof token?.meta?.alertType === "string"
+ ? token.meta.alertType
+ : "note";
+ return `${markdown.utils.escapeHtml(token?.content ?? "")}`;
+};
+markdown.renderer.rules.markdown_alert_body_break = () => "\n";
+
const defaultValidateLink = markdown.validateLink.bind(markdown);
markdown.validateLink = (href) =>
defaultValidateLink(href) ||
@@ -141,6 +241,13 @@ markdown.renderer.rules.paragraph_open = (
if (standaloneImage) {
return '\n';
}
+ const inlineToken = tokens[index + 1];
+ if (
+ inlineToken?.type === "inline" &&
+ (inlineToken.children ?? []).some((child) => child.type === "code_inline")
+ ) {
+ tokens[index]?.attrJoin("class", "md-inline-code-paragraph");
+ }
return defaultParagraphOpenRenderer
? defaultParagraphOpenRenderer(
tokens,
@@ -367,6 +474,7 @@ export function renderMarkdown(
return {
rendererVersion: RENDERER_VERSION,
+ markdownBody: parsed.content,
articleHtml,
bodyHtml,
metadata,
diff --git a/packages/renderer/tests/render-markdown.test.ts b/packages/renderer/tests/render-markdown.test.ts
index dde1698..9b4af5b 100644
--- a/packages/renderer/tests/render-markdown.test.ts
+++ b/packages/renderer/tests/render-markdown.test.ts
@@ -6,6 +6,38 @@ import {
} from "../src/render-markdown.js";
describe("renderMarkdown", () => {
+ it("将 GFM Alert 渲染为与 Pandoc 一致的独立标题引用块", () => {
+ const result = renderMarkdown(
+ "> [!CAUTION]\n> **关键约束**:必须执行。",
+ );
+
+ expect(result.bodyHtml).toContain(
+ '',
+ );
+ expect(result.bodyHtml).toContain(
+ 'Caution
\n关键约束:必须执行。
',
+ );
+ expect(result.bodyHtml).not.toContain("[!CAUTION]");
+ });
+
+ it("为含行内代码的普通段落标记分页保护类", () => {
+ const result = renderMarkdown("段落前 `inline_code` 段落后");
+
+ expect(result.articleHtml).toContain(
+ '段落前 inline_code 段落后
',
+ );
+ });
+
+ it("向下游暴露已剥离文首 Front Matter 的正文 Markdown", () => {
+ const rendered = renderMarkdown(
+ "---\ntitle: 测试标题\n---\n# 正文\n\n---\n\n后续内容"
+ );
+
+ expect(rendered.markdownBody).toBe(
+ "# 正文\n\n---\n\n后续内容"
+ );
+ expect(rendered.metadata.title).toBe("测试标题");
+ });
it("渲染常用 Markdown 扩展并识别能力", () => {
const result = renderMarkdown(`
# 示例文档
@@ -80,6 +112,39 @@ const answer = 42;
expect(result.bodyHtml).toContain('C | ');
});
+ it("只在 GFM 表格单元格内解释无属性 br 换行", () => {
+ const result = renderMarkdown(`
+| 场景 | 内容 |
+| --- | --- |
+| 标准 | 第一行
第二行
第三行
第四行 |
+
+正文中的
、
和
保持文本。
+
+| 转义与代码 | 内容 |
+| --- | --- |
+| 转义 | \\
与 <br> |
+| 代码 | \`
\` |
+| 属性 |
|
+
+\`\`\`html
+
+\`\`\`
+`);
+
+ expect(result.bodyHtml).toContain(
+ "第一行
第二行
第三行
第四行"
+ );
+ expect(result.bodyHtml).toContain(
+ "正文中的 <br>、<br/> 和 <br /> 保持文本"
+ );
+ expect(result.bodyHtml).toContain("<br> 与 <br>");
+ expect(result.bodyHtml).toContain("<br>");
+ expect(result.bodyHtml).toContain("<br class=\"unsafe\">");
+ expect(result.bodyHtml).toContain(
+ ''
+ );
+ });
+
it("读取并规范化 Front Matter 元数据", () => {
const result = renderMarkdown(`---
title: 项目报告
diff --git a/scripts/build-font-packs.mjs b/scripts/build-font-packs.mjs
index 93fed62..35c11cb 100644
--- a/scripts/build-font-packs.mjs
+++ b/scripts/build-font-packs.mjs
@@ -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 ||
diff --git a/scripts/docx-layout-visual-matrix-cases.mjs b/scripts/docx-layout-visual-matrix-cases.mjs
index a691aea..0fcbf9e 100644
--- a/scripts/docx-layout-visual-matrix-cases.mjs
+++ b/scripts/docx-layout-visual-matrix-cases.mjs
@@ -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,
diff --git a/scripts/docx-layout-visual-matrix-cases.test.mjs b/scripts/docx-layout-visual-matrix-cases.test.mjs
index b23644d..1152e33 100644
--- a/scripts/docx-layout-visual-matrix-cases.test.mjs
+++ b/scripts/docx-layout-visual-matrix-cases.test.mjs
@@ -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);
+});
diff --git a/scripts/docx-real-world-corpus.mjs b/scripts/docx-real-world-corpus.mjs
new file mode 100644
index 0000000..5787adb
--- /dev/null
+++ b/scripts/docx-real-world-corpus.mjs
@@ -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
+ };
+}
diff --git a/scripts/docx-real-world-corpus.test.mjs b/scripts/docx-real-world-corpus.test.mjs
new file mode 100644
index 0000000..86bc68e
--- /dev/null
+++ b/scripts/docx-real-world-corpus.test.mjs
@@ -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
+ }
+ );
+});
diff --git a/scripts/docx-release-gate-suites.mjs b/scripts/docx-release-gate-suites.mjs
new file mode 100644
index 0000000..c1cfdc9
--- /dev/null
+++ b/scripts/docx-release-gate-suites.mjs
@@ -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
+ };
+}
diff --git a/scripts/docx-release-gate-suites.test.mjs b/scripts/docx-release-gate-suites.test.mjs
new file mode 100644
index 0000000..715373b
--- /dev/null
+++ b/scripts/docx-release-gate-suites.test.mjs
@@ -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 });
+ }
+});
diff --git a/scripts/verify-docx-layout-visual-matrix.mjs b/scripts/verify-docx-layout-visual-matrix.mjs
index bb8b380..9bbcbab 100644
--- a/scripts/verify-docx-layout-visual-matrix.mjs
+++ b/scripts/verify-docx-layout-visual-matrix.mjs
@@ -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: {
diff --git a/scripts/verify-docx-release-gate-560.mjs b/scripts/verify-docx-release-gate-560.mjs
new file mode 100644
index 0000000..bb0dad7
--- /dev/null
+++ b/scripts/verify-docx-release-gate-560.mjs
@@ -0,0 +1,4 @@
+process.stderr.write(
+ "verify-docx-release-gate-560 已改为兼容入口:每次只执行下一套独立 140,不再启动整批 560。\n"
+);
+await import("./verify-docx-release-gate-suite.mjs");
diff --git a/scripts/verify-docx-release-gate-suite.mjs b/scripts/verify-docx-release-gate-suite.mjs
new file mode 100644
index 0000000..0f9f9e6
--- /dev/null
+++ b/scripts/verify-docx-release-gate-suite.mjs
@@ -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("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """);
+}
+
+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) =>
+ `| ${escapeHtml(entry.caseId)} | ${escapeHtml(entry.status)} | ${escapeHtml(entry.finishedAt || "")} | ${entry.summaryPath ? `JSON` : "-"} | ${escapeHtml(entry.error || entry.gateFailures?.join(";") || "")} |
`
+ ).join("");
+ return `${escapeHtml(aggregate.suite.label)}${escapeHtml(aggregate.suite.label)}
${aggregate.execution.completedCaseCount}/${aggregate.execution.expectedCaseCount};通过 ${aggregate.execution.passedCaseCount};门禁失败 ${aggregate.execution.gateFailedCaseCount};基础设施错误 ${aggregate.execution.errorCaseCount}。
`;
+}
+
+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 }) =>
+ `| ${suite.order + 1} | ${escapeHtml(suite.label)} | ${aggregate?.execution?.completedCaseCount ?? 0}/140 | ${aggregate?.gatePassed ? "通过" : aggregate ? "未通过/未完成" : "未开始"} | 报告 |
`
+ ).join("");
+ fs.writeFileSync(path.join(outputRoot, "index.html"), `DOCX 4×140 发布门禁DOCX 4×140 独立发布门禁
先通过标准合成基线,再按 Markdown 字节数从小到大逐套执行。
`, "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;
+}