Files
MorphDoc/deploy/verify-font-pack-compose.mjs
SkyJourneyandClaude Sonnet 5 c4df499680 release: 发布 v0.6.4 环境自适应与 macOS 打包
新增能力:桌面端新增 macOS 打包支持,electron-builder 增加独立 mac 配置块,
产出 dmg,按 arm64、x64 分别单独构建、不产出 universal 包;Pandoc 运行时清单
新增 darwin/arm64、darwin/x64 两个官方发行项(含独立许可证文件来源),桌面
Pandoc 候选路径与 prepare-pandoc-runtime.mjs 均已泛化为按 --platform/--arch
参数选取目标,不再只认 Windows x64。AGENTS.md 不再硬编码 Windows 绝对路径与
强制 PowerShell 语法,改为按当前 Shell 与仓库实际位置自适应。

问题修复:修复解压内置 Pandoc 时未保留 Unix 可执行权限位的问题(unzipSync
不保留压缩包内权限,已补 chmod 0o755);修复根 package.json 中
build:web-runtime/dev/desktop:dev 三个脚本的构建顺序错误
(@md-to-pdf/application 被排在其依赖的 @md-to-pdf/preview-engine 之前,在
没有历史 dist 残留的全新环境中会导致类型解析失败);修正
packages/docx-engine/tests/pandoc-runtime.test.ts 与
apps/desktop/tests/markdown-file.test.ts 中依赖宿主 OS 或路径分隔符的测试
断言,避免这些用例只能在特定平台上通过。

验证结果:docx-engine 包 93 项测试、desktop 包 62 项测试(61 通过 + 1 项
Windows 专属用例按预期跳过)均通过;全项目类型检查、生产构建通过,
git diff --check 无空白错误。跳过依赖 Git 忽略的真实客户文档语料(tmp/)的
test:docx-real-world-corpus 与 test:docx-release-gate-suites 后,其余全部
工作区测试均通过;这两步需要接入真实语料的机器上单独执行。本机 macOS
(Apple Silicon)实测 npm run package:mac:arm64 全链路成功,内置 Pandoc
3.9.0.2 完成下载、哈希校验与真实 DOCX 冒烟转换;实际截图确认窗口、macOS
原生菜单栏、编辑器工具栏、中文字体与实时预览渲染正常。

兼容与部署:版本统一为 0.6.4。本版本仅完成开发基础设施与验证,未构建正式
发行文件:没有产出真实签名的 dmg、没有重新构建 Windows 安装包,也没有重新
构建 Docker 镜像;正式 macOS 发行产物与 Windows/Docker 同步验证留待 DOCX
发布门禁阶段完成后一并发布。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K5N6pcbjV4jVfXrcH3NcLP
2026-08-26 19:32:38 +08:00

282 lines
8.6 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { spawnSync } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
import { mkdir, mkdtemp, readFile, rm, stat } from "node:fs/promises";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { defaultExportConfig } from "@md-to-pdf/core";
import { strFromU8, unzipSync } from "fflate";
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
const repositoryRoot = path.resolve(scriptDirectory, "..");
const composeFile = path.join(scriptDirectory, "compose.yaml");
const image = process.env.MD_TO_PDF_IMAGE?.trim() || "md-to-pdf:local";
const fontPackRoot = path.resolve(
path.join(repositoryRoot, "output", "font-packs", "root")
);
const expectedFontPacks = [
{ id: "mdtp-serif-sc", version: "1.0.0" },
{ id: "mdtp-sans-sc", version: "1.0.0" },
{ id: "mdtp-mono", version: "1.0.0" }
];
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function sha256(content) {
return createHash("sha256").update(content).digest("hex").toLowerCase();
}
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: repositoryRoot,
encoding: "utf8",
stdio: options.capture ? "pipe" : "inherit",
env: options.env ?? process.env
});
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
throw new Error(
`${command} ${args.join(" ")} 失败(退出码 ${result.status}${
options.capture ? `\n${result.stderr || result.stdout}` : ""
}`
);
}
return options.capture ? result.stdout.trim() : "";
}
async function freePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
const port =
typeof address === "object" && address ? address.port : undefined;
server.close((error) =>
error || !port
? reject(error ?? new Error("无法分配端口"))
: resolve(port)
);
});
});
}
async function waitForHealth(origin) {
let lastError;
for (let attempt = 0; attempt < 60; attempt += 1) {
try {
const response = await fetch(`${origin}/api/health`);
if (response.ok && (await response.json()).status === "ok") {
return;
}
} catch (error) {
lastError = error;
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
throw new Error(
`容器健康检查未就绪${lastError ? `${String(lastError)}` : ""}`
);
}
function assertNoFontPackMount(containerId) {
const inspection = JSON.parse(
run("docker", ["inspect", containerId], { capture: true })
)[0];
const mount = inspection.Mounts.find(
(item) => item.Destination === "/app/.local/font-packs"
);
assert(!mount, "Docker 字体包必须来自镜像层,不能依赖运行时挂载");
}
async function exportDocx(origin) {
const response = await fetch(`${origin}/api/docx`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
markdown:
"---\ntitle: Docker 内置字体包验收\n---\n\n# Docker 内置字体包验收\n\n这是一段包含行内代码 `const value = 1` 的可编辑中文正文,代码前后不得换行。",
fileName: "Docker 内置字体包验收.md",
language: "zh-CN",
resources: [],
exportConfig: {
...defaultExportConfig,
themeId: "gov-red-standard"
}
})
});
if (!response.ok) {
throw new Error(
`DOCX 导出失败(${response.status}):${await response.text()}`
);
}
return new Uint8Array(await response.arrayBuffer());
}
const fontPackStatus = await stat(fontPackRoot).catch(() => undefined);
assert(
fontPackStatus?.isDirectory(),
`未找到有效字体包根目录:${fontPackRoot}。请先运行 npm run build:font-pack。`
);
const localManifests = await Promise.all(
expectedFontPacks.map(async (pack) => ({
...pack,
content: await readFile(
path.join(fontPackRoot, pack.id, pack.version, "font-pack.json")
)
}))
);
const buildEnvironment = {
...process.env,
MD_TO_PDF_IMAGE: image
};
run(
"docker",
["compose", "--file", composeFile, "build", "app"],
{ env: buildEnvironment }
);
run("docker", ["image", "inspect", image], { capture: true });
const embeddedManifestHashes = {};
for (const manifest of localManifests) {
const embeddedHash = run(
"docker",
[
"run",
"--rm",
"--entrypoint",
"sha256sum",
image,
`/app/.local/font-packs/${manifest.id}/${manifest.version}/font-pack.json`
],
{ capture: true }
).split(/\s+/u)[0];
assert(
embeddedHash === sha256(manifest.content),
`镜像内字体包清单不一致:${manifest.id}@${manifest.version}`
);
embeddedManifestHashes[`${manifest.id}@${manifest.version}`] = embeddedHash;
}
const port = await freePort();
const projectName = `morphdoc-font-pack-${randomUUID().slice(0, 8)}`;
const temporaryRoot = await mkdtemp(
path.join(os.tmpdir(), "morphdoc-docker-font-pack-")
);
const themeRoot = path.join(temporaryRoot, "empty-themes");
await mkdir(themeRoot, { recursive: true });
const environment = {
...buildEnvironment,
MD_TO_PDF_PORT: String(port),
MD_TO_PDF_THEME_DIR: themeRoot,
MD_TO_PDF_APP_VERSION: "0.6.4"
};
const compose = [
"compose",
"--project-name",
projectName,
"--file",
composeFile
];
try {
run(
"docker",
[...compose, "up", "--detach", "--no-build", "--pull", "never"],
{ env: environment }
);
const containerId = run(
"docker",
[...compose, "ps", "--quiet", "app"],
{ capture: true, env: environment }
);
assert(containerId, "未创建 Docker 字体包验收容器");
assertNoFontPackMount(containerId);
const origin = `http://127.0.0.1:${port}`;
await waitForHealth(origin);
const capabilityResponse = await fetch(`${origin}/api/docx/capability`);
const capability = await capabilityResponse.json();
assert(
capabilityResponse.ok &&
capability.status === "available" &&
capability.detectedVersion === "3.9.0.2",
`DOCX capability 无效:${JSON.stringify(capability)}`
);
const cssResponse = await fetch(
`${origin}/api/themes/gov-red-standard/css`
);
const css = await cssResponse.text();
assert(cssResponse.ok, "主题 CSS 请求失败");
assert(css.includes("md-to-pdf-font-packs:"), "主题 CSS 缺少字体包标记");
for (const pack of expectedFontPacks) {
assert(
css.includes(`${pack.id}@${pack.version}`),
`Docker 内置字体包未进入主题 CSS:${pack.id}@${pack.version}`
);
}
const assetPath = css.match(
/(\/api\/font-packs\/[^"'()\s]+\/web\?v=[a-f0-9]+)/u
)?.[1];
assert(assetPath, "Docker 内置字体包 CSS 缺少受控 WOFF2 地址");
const assetResponse = await fetch(`${origin}${assetPath}`);
assert(assetResponse.ok, "Docker 内置字体包 WOFF2 请求失败");
assert(
assetResponse.headers.get("content-type")?.includes("font/woff2"),
"Docker 内置字体包 WOFF2 MIME 无效"
);
assert(
assetResponse.headers.get("cache-control")?.includes("immutable"),
"Docker 内置字体包 WOFF2 缓存策略无效"
);
const docx = await exportDocx(origin);
const entries = unzipSync(docx);
const fontParts = Object.keys(entries).filter((entry) =>
entry.startsWith("word/fonts/")
);
assert(fontParts.length >= 2, `DOCX 字体部件不足:${fontParts.length}`);
const fontTable = strFromU8(entries["word/fontTable.xml"]);
assert(fontTable.includes("MdTP Serif SC"), "字体包未进入 DOCX 字体表");
assert(fontTable.includes("MdTP Mono"), "等宽字体包未进入 DOCX 字体表");
const documentXml = strFromU8(entries["word/document.xml"]);
const inlineCodeParagraph = documentXml
.match(/<w:p(?:\s[^>]*)?>[\s\S]*?<\/w:p>/gu)
?.find(
(paragraph) =>
paragraph.includes("这是一段包含行内代码") &&
paragraph.includes("const value = 1") &&
paragraph.includes("代码前后不得换行")
);
assert(inlineCodeParagraph, "行内代码及前后正文未保持在同一 Word 段落");
process.stdout.write(
`${JSON.stringify(
{
image,
embeddedManifestSha256: embeddedManifestHashes,
runtimeFontPackMount: false,
fontPackAvailable: true,
pandocVersion: capability.detectedVersion,
docxFontParts: fontParts.length
},
null,
2
)}\n`
);
} finally {
run(
"docker",
[...compose, "down", "--remove-orphans", "--timeout", "5"],
{ env: environment }
);
await rm(temporaryRoot, { recursive: true, force: true });
}