feat: 完成 Docker DOCX 与可选字体包部署链

This commit is contained in:
SkyJourney
2026-08-01 08:56:20 +08:00
parent 7f2c169405
commit 3081c02887
8 changed files with 577 additions and 9 deletions
+320
View File
@@ -0,0 +1,320 @@
import { spawnSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import {
cp,
mkdir,
mkdtemp,
readFile,
rm,
stat,
writeFile
} 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 validFontPackRoot = path.resolve(
process.env.MD_TO_PDF_FONT_PACK_DIR?.trim() ||
path.join(repositoryRoot, "output", "font-packs", "root")
);
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
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 < 45; 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 inspectFontPackMount(containerId, expectedRoot) {
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, "容器缺少字体包挂载");
assert(mount.RW === false, "字体包挂载不是只读");
assert(
path.resolve(mount.Source) === path.resolve(expectedRoot),
`字体包挂载源不匹配:${mount.Source}`
);
}
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这是一段可编辑中文正文。",
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());
}
async function runScenario({ name, root, expected }) {
const port = await freePort();
const projectName = `mdtp-fp3c-${name}-${randomUUID().slice(0, 8)}`;
const environment = {
...process.env,
MD_TO_PDF_IMAGE: image,
MD_TO_PDF_PORT: String(port),
MD_TO_PDF_FONT_PACK_DIR: root,
MD_TO_PDF_THEME_DIR: path.join(path.dirname(root), "empty-themes"),
MD_TO_PDF_APP_VERSION: "0.6.0"
};
await mkdir(environment.MD_TO_PDF_THEME_DIR, { recursive: true });
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, `${name} 场景未创建容器`);
inspectFontPackMount(containerId, root);
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",
`${name} 场景 DOCX capability 无效:${JSON.stringify(capability)}`
);
const cssResponse = await fetch(
`${origin}/api/themes/gov-red-standard/css`
);
const css = await cssResponse.text();
assert(cssResponse.ok, `${name} 场景主题 CSS 请求失败`);
const marker = "md-to-pdf-font-packs:mdtp-serif-sc@1.0.0";
assert(
css.includes(marker) === expected.fontPackAvailable,
`${name} 场景字体包标记状态不符合预期`
);
if (expected.fontPackAvailable) {
const assetPath = css.match(
/(\/api\/font-packs\/[^"'()\s]+\/web\?v=[a-f0-9]+)/u
)?.[1];
assert(assetPath, "有效字体包 CSS 缺少受控 WOFF2 地址");
const assetResponse = await fetch(`${origin}${assetPath}`);
assert(assetResponse.ok, "有效字体包 WOFF2 请求失败");
assert(
assetResponse.headers.get("content-type")?.includes("font/woff2"),
"有效字体包 WOFF2 MIME 无效"
);
assert(
assetResponse.headers.get("cache-control")?.includes("immutable"),
"有效字体包 WOFF2 缓存策略无效"
);
assert(
(await assetResponse.arrayBuffer()).byteLength > 1024 * 1024,
"有效字体包 WOFF2 内容异常"
);
}
if (expected.exportDocx) {
const docx = await exportDocx(origin);
const entries = unzipSync(docx);
const fontParts = Object.keys(entries).filter((entry) =>
entry.startsWith("word/fonts/")
);
assert(
fontParts.length >= expected.minimumFontParts,
`${name} 场景 DOCX 字体部件不足:${fontParts.length}`
);
if (expected.fontPackAvailable) {
const fontTable = strFromU8(entries["word/fontTable.xml"]);
assert(
fontTable.includes("MdTP Serif SC"),
"有效字体包未进入 DOCX 字体表"
);
}
}
return {
name,
fontPackAvailable: expected.fontPackAvailable,
docxExported: expected.exportDocx,
readOnlyMount: true
};
} finally {
run(
"docker",
[...compose, "down", "--remove-orphans", "--timeout", "5"],
{ env: environment }
);
}
}
const validRootStatus = await stat(validFontPackRoot).catch(
() => undefined
);
assert(
validRootStatus?.isDirectory(),
`未找到有效字体包根目录:${validFontPackRoot}。请先运行 npm run build:font-pack。`
);
run("docker", ["image", "inspect", image], { capture: true });
run("docker", [
"run",
"--rm",
"--entrypoint",
"sh",
image,
"-c",
"test ! -e /app/.local/font-packs"
]);
const temporaryRoot = await mkdtemp(
path.join(os.tmpdir(), "mdtp-docker-font-pack-")
);
try {
const emptyRoot = path.join(temporaryRoot, "empty-font-packs");
const damagedRoot = path.join(temporaryRoot, "damaged-font-packs");
await mkdir(emptyRoot, { recursive: true });
await cp(validFontPackRoot, damagedRoot, { recursive: true });
const manifestPath = path.join(
damagedRoot,
"mdtp-serif-sc",
"1.0.0",
"font-pack.json"
);
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
const damagedAssetPath = path.join(
path.dirname(manifestPath),
manifest.faces[0].web.path
);
const damagedAsset = await readFile(damagedAssetPath);
damagedAsset[0] ^= 0xff;
await writeFile(damagedAssetPath, damagedAsset);
const results = [];
results.push(
await runScenario({
name: "empty",
root: emptyRoot,
expected: {
fontPackAvailable: false,
exportDocx: false,
minimumFontParts: 0
}
})
);
results.push(
await runScenario({
name: "valid",
root: validFontPackRoot,
expected: {
fontPackAvailable: true,
exportDocx: true,
minimumFontParts: 2
}
})
);
results.push(
await runScenario({
name: "damaged",
root: damagedRoot,
expected: {
fontPackAvailable: false,
exportDocx: true,
minimumFontParts: 0
}
})
);
process.stdout.write(`${JSON.stringify({ image, results }, null, 2)}\n`);
} finally {
await rm(temporaryRoot, { recursive: true, force: true });
}