release: 发布 v0.6.1 DOCX 视觉一致性修复

新增通用 CSS 到 OOXML 翻译修复,统一字体、字距、精确行距、段落、列表、表格、引用、代码块与行内代码连续性,不引入按主题 ID 分支。

新增 MdTP Mono 并统一 Serif、Sans、Mono 三字体包的 Chromium 与 DOCX 使用链;字体声明、嵌入部件和 Word/WPS 实际采用均进入硬门禁。

重建封面整页及正文语义块视觉差分,14 套主题、纵横两个方向、五组页边距共 140 个真实场景全部通过,阻断失败和诊断失败均为零。

源码服务、Docker Web API 与实际安装 Desktop 的 red-briefing 导出均包含 5 个字体部件;Word/WPS 原生渲染和逐页复核通过。修复 Docker 构建上下文与运行层复用软链接,并完善 v0.6.1 版本、发行说明和发布归集。

验证:npm test(116 个文件、616 项测试)、npm run typecheck、npm run build、git diff --check 全部通过。Desktop 安装器与 ZIP、Docker v0.6.1 镜像已生成;Windows 产物仍为未签名内部发行。
This commit is contained in:
SkyJourney
2026-08-04 10:30:44 +08:00
parent b275c671fc
commit 2c5c1bd317
84 changed files with 4404 additions and 344 deletions
+150 -1
View File
@@ -60,11 +60,21 @@ const selectedCaseId =
process.env.MD_TO_PDF_LAYOUT_VISUAL_CASE?.trim() || undefined;
const selectedScope =
process.env.MD_TO_PDF_LAYOUT_VISUAL_SCOPE?.trim() || "full";
const selectedCases = selectedCaseId
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 scopedCases = selectedCaseId
? cases.filter((entry) => entry.id === selectedCaseId)
: selectedScope === "cover"
? cases.filter((entry) => entry.coverPageCount > 0)
: cases;
const selectedCases = scopedCases.filter(
(entry) =>
(!selectedOrientation || entry.orientation === selectedOrientation) &&
(!selectedMarginScenarioId ||
entry.marginScenarioId === selectedMarginScenarioId)
);
function assert(condition, message) {
if (!condition) {
@@ -121,6 +131,60 @@ function isInternalLayoutSpacerParagraph(descendants, text) {
);
}
function hasAncestor(paragraph, localName) {
let current = paragraph.parentNode;
while (current) {
if (
current.localName === localName &&
current.namespaceURI === WORD_NAMESPACE
) {
return true;
}
current = current.parentNode;
}
return false;
}
function isTableHeaderParagraph(paragraph) {
let current = paragraph.parentNode;
while (current) {
if (current.localName === "tr" && current.namespaceURI === WORD_NAMESPACE) {
return Array.from(current.getElementsByTagName("*")).some(
(node) =>
node.localName === "tblHeader" &&
node.namespaceURI === WORD_NAMESPACE
);
}
current = current.parentNode;
}
return false;
}
function paragraphBlockKind(paragraph, styleId) {
if (hasAncestor(paragraph, "tc")) {
return isTableHeaderParagraph(paragraph) ? "table-header" : "table-cell";
}
if (styleId === "SourceCode") {
return "code-block";
}
if (styleId === "BlockText") {
return "block-quote";
}
if (/(?:Caption)$/u.test(styleId)) {
return "caption";
}
if (/^Heading[1-6]$/u.test(styleId)) {
return "heading";
}
if (styleId === "Compact" || styleId === "ListParagraph") {
return "list-item";
}
if (/^Md(?:Official|Briefing|ProjectReport|Tender)/u.test(styleId)) {
return "semantic-region";
}
return "paragraph";
}
function extractEditableContract(docxPath) {
const entries = unzipSync(Uint8Array.from(fs.readFileSync(docxPath)));
const documentXml = entries["word/document.xml"];
@@ -161,6 +225,7 @@ function extractEditableContract(docxPath) {
text,
...(styleId ? { styleId } : {}),
role: paragraphRole(styleId),
blockKind: paragraphBlockKind(paragraph, styleId),
section
});
}
@@ -217,6 +282,62 @@ function pageGeometryFailures(snapshot, caseDefinition, label) {
});
}
const inlineCodeContinuityProbes = [
{
id: "paragraph",
code: "mdtp_ic_p",
continuousText: "段落前mdtp_ic_p段落后"
},
{
id: "list-item",
code: "mdtp_ic_l",
continuousText: "列表前mdtp_ic_l列表后"
},
{
id: "table-cell",
code: "mdtp_ic_c",
continuousText: "单元格前mdtp_ic_c单元格后"
}
];
function inspectInlineCodeContinuity(snapshot, label) {
const normalizedLines = snapshot.pages.flatMap((page) =>
page.lines.map((line) => ({
pageNumber: page.pageNumber,
text: line.text,
normalizedText: line.text.normalize("NFKC").replace(/\s+/gu, "")
}))
);
const probes = inlineCodeContinuityProbes.map((probe) => {
const matchingLines = normalizedLines.filter((line) =>
line.normalizedText.includes(probe.code)
);
const passed =
matchingLines.length === 1 &&
matchingLines[0].normalizedText.includes(probe.continuousText);
return {
...probe,
passed,
matchingLines: matchingLines.map(({ pageNumber, text }) => ({
pageNumber,
text
}))
};
});
return {
label,
passed: probes.every((probe) => probe.passed),
probes,
failures: probes.flatMap((probe) =>
probe.passed
? []
: [
`${label}: INLINE_CODE_CONTINUITY_MISMATCH - ${probe.id} 行内代码未与前后文本保持同一视觉行`
]
)
};
}
function buildPageSemantics(pageCount, coverPageCount, config) {
assert(
pageCount > coverPageCount,
@@ -481,6 +602,17 @@ assert(
selectedScope === "full" || selectedScope === "cover",
`不支持的视觉矩阵作用域:${selectedScope}`
);
assert(
!selectedOrientation || ["portrait", "landscape"].includes(selectedOrientation),
`不支持的视觉矩阵方向:${selectedOrientation}`
);
assert(
!selectedMarginScenarioId ||
matrixDefinition.marginScenarios.some(
(entry) => entry.id === selectedMarginScenarioId
),
`不支持的视觉矩阵边距场景:${selectedMarginScenarioId}`
);
const wordAdapter = createWordPdfAdapter({ timeoutMs: 240_000 });
const wpsAdapter = createWpsPdfAdapter({ timeoutMs: 240_000 });
const wordCapability = await wordAdapter.probe();
@@ -578,6 +710,11 @@ for (const caseDefinition of selectedCases) {
baselinePageSemantics: wordPageSemantics,
candidatePageSemantics: wpsPageSemantics
});
const inlineCodeContinuity = {
chromium: inspectInlineCodeContinuity(chromium, "Chromium"),
word: inspectInlineCodeContinuity(word, "Microsoft Word"),
wps: inspectInlineCodeContinuity(wps, "WPS Writer")
};
const rasterDirectory = path.join(caseDirectory, "raster-pages");
fs.rmSync(rasterDirectory, { recursive: true, force: true });
@@ -605,6 +742,9 @@ for (const caseDefinition of selectedCases) {
renderedFonts: wps.fonts,
engine: "wps"
}),
...inlineCodeContinuity.chromium.failures,
...inlineCodeContinuity.word.failures,
...inlineCodeContinuity.wps.failures,
...reportGateFailures(wordReport, "Chromium/Word"),
...reportGateFailures(wpsReport, "Chromium/WPS"),
...reportGateFailures(officeReport, "Word/WPS")
@@ -647,6 +787,7 @@ for (const caseDefinition of selectedCases) {
wps: wps.fonts
},
editableParagraphCount: editable.paragraphs.length,
inlineCodeContinuity,
reports: {
word: reportSummary(wordReport),
wps: reportSummary(wpsReport),
@@ -670,9 +811,13 @@ const summary = {
matrixDefinition,
selectedCaseId,
selectedScope,
selectedOrientation,
selectedMarginScenarioId,
execution: {
mode: selectedCaseId
? "single-case"
: selectedOrientation || selectedMarginScenarioId
? "filtered-matrix"
: selectedScope === "cover"
? "cover-matrix"
: "full-matrix",
@@ -693,6 +838,8 @@ const summaryPath = path.join(
outputDirectory,
selectedCaseId
? `summary-${selectedCaseId}.json`
: selectedOrientation || selectedMarginScenarioId
? `summary-${selectedOrientation ?? "all"}-${selectedMarginScenarioId ?? "all"}.json`
: selectedScope === "cover"
? "summary-cover.json"
: "summary.json"
@@ -701,6 +848,8 @@ const matrixHtmlPath = path.join(
outputDirectory,
selectedCaseId
? `matrix-${selectedCaseId}.html`
: selectedOrientation || selectedMarginScenarioId
? `matrix-${selectedOrientation ?? "all"}-${selectedMarginScenarioId ?? "all"}.html`
: selectedScope === "cover"
? "matrix-cover.html"
: "matrix.html"