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
+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: {