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:
@@ -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 ||
|
||||
|
||||
@@ -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 套内置主题都声明字体且中文字体面不超过三个", () => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user