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
+1 -1
View File
@@ -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.0";
const appVersion = process.env.npm_package_version || "0.6.1";
if (
bundle.schemaVersion !== 1 ||
+2 -2
View File
@@ -157,9 +157,9 @@ export function getDocxFontGateFailures({
if (embeddedPartCount === 0) {
failures.push(`主题 ${theme.id} 的 DOCX 没有嵌入字体部件`);
}
if (declaredFaces.length > 0 && embeddedPartCount !== declaredFaces.length) {
if (declaredFaces.length > 0 && embeddedPartCount < declaredFaces.length) {
failures.push(
`主题 ${theme.id} 声明 ${declaredFaces.length} 个字体面,但 DOCX 实际嵌入 ${embeddedPartCount} 个部件`
`主题 ${theme.id} 声明 ${declaredFaces.length} 个字体面,但 DOCX 嵌入 ${embeddedPartCount} 个部件`
);
}
if (embeddedPartCount > 0) {
@@ -65,6 +65,20 @@ test("字体门禁区分缺少声明、缺少嵌入和 Office 未实际采用",
engine: "word"
});
assert.deepEqual(embedded, []);
const embeddedWithEngineFaces = getDocxFontGateFailures({
theme: {
id: "engine-faces",
docxFontFaces: [{ family: "FandolSong", aliases: [], weight: 400, style: "normal" }]
},
inspection: {
embeddedFontPartCount: 3,
embeddedFontNames: ["MdTP Serif SC", "MdTP Mono"]
},
renderedFonts: [{ name: "WPSEMBED1" }],
engine: "wps"
});
assert.deepEqual(embeddedWithEngineFaces, []);
});
test("14 套内置主题都声明字体且中文字体面不超过三个", () => {
+29
View File
@@ -186,6 +186,28 @@ export async function stageRelease(options = {}) {
`MorphDoc-${version}-x86_64-Setup.exe`,
`MorphDoc-${version}-x86_64.zip`
];
const fontPackArtifacts = [
{
name: "MorphDoc-FontPack-mdtp-serif-sc-1.0.0-x86_64-Setup.exe",
source: join(
repositoryRoot,
"output",
"font-packs",
"windows",
"MorphDoc-FontPack-mdtp-serif-sc-1.0.0-x86_64-Setup.exe"
)
},
{
name: "MorphDoc-FontPack-mdtp-serif-sc-1.0.0.zip",
source: join(
repositoryRoot,
"output",
"font-packs",
"release",
"MorphDoc-FontPack-mdtp-serif-sc-1.0.0.zip"
)
}
];
const releaseNotesSource = join(
repositoryRoot,
"docs",
@@ -195,6 +217,9 @@ export async function stageRelease(options = {}) {
for (const name of desktopNames) {
await assertReadableFile(join(desktopDirectory, name));
}
for (const artifact of fontPackArtifacts) {
await assertReadableFile(artifact.source);
}
await assertReadableFile(releaseNotesSource);
await mkdir(stageDirectory, { recursive: true });
@@ -202,6 +227,9 @@ export async function stageRelease(options = {}) {
for (const name of desktopNames) {
await copyFile(join(desktopDirectory, name), join(stageDirectory, name));
}
for (const artifact of fontPackArtifacts) {
await copyFile(artifact.source, join(stageDirectory, artifact.name));
}
await copyFile(releaseNotesSource, join(stageDirectory, "RELEASE-NOTES.md"));
const deployName = `MorphDoc-Web-${version}-deploy.zip`;
@@ -221,6 +249,7 @@ export async function stageRelease(options = {}) {
const primaryNames = [
...desktopNames,
...fontPackArtifacts.map((artifact) => artifact.name),
imageName,
deployName,
"RELEASE-NOTES.md"
+17
View File
@@ -11,6 +11,11 @@ import {
parseArguments
} from "./stage-release.mjs";
const stageReleaseSource = await readFile(
new URL("./stage-release.mjs", import.meta.url),
"utf8"
);
test("normalizeVersion 接受语义版本并移除 v 前缀", () => {
assert.equal(normalizeVersion("v0.6.0"), "0.6.0");
assert.equal(normalizeVersion("1.2.3-beta.1"), "1.2.3-beta.1");
@@ -55,3 +60,15 @@ test("Web 离线部署 ZIP 包含 Compose 和导入说明", async () => {
await rm(directory, { recursive: true, force: true });
}
});
test("正式发布强制归集独立字体安装器和 ZIP", () => {
assert.match(
stageReleaseSource,
/MorphDoc-FontPack-mdtp-serif-sc-1\.0\.0-x86_64-Setup\.exe/u
);
assert.match(
stageReleaseSource,
/MorphDoc-FontPack-mdtp-serif-sc-1\.0\.0\.zip/u
);
assert.match(stageReleaseSource, /fontPackArtifacts\.map/u);
});
+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"