feat: 完善字体包发行与 Docker 内置字体
This commit is contained in:
+143
-206
@@ -1,14 +1,6 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
cp,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
stat,
|
||||
writeFile
|
||||
} from "node:fs/promises";
|
||||
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";
|
||||
@@ -20,9 +12,14 @@ 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")
|
||||
const fontPackRoot = path.resolve(
|
||||
path.join(repositoryRoot, "output", "font-packs", "root")
|
||||
);
|
||||
const manifestPath = path.join(
|
||||
fontPackRoot,
|
||||
"mdtp-serif-sc",
|
||||
"1.0.0",
|
||||
"font-pack.json"
|
||||
);
|
||||
|
||||
function assert(condition, message) {
|
||||
@@ -31,6 +28,10 @@ function assert(condition, message) {
|
||||
}
|
||||
}
|
||||
|
||||
function sha256(content) {
|
||||
return createHash("sha256").update(content).digest("hex").toLowerCase();
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: repositoryRoot,
|
||||
@@ -58,9 +59,7 @@ async function freePort() {
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
const port =
|
||||
typeof address === "object" && address
|
||||
? address.port
|
||||
: undefined;
|
||||
typeof address === "object" && address ? address.port : undefined;
|
||||
server.close((error) =>
|
||||
error || !port
|
||||
? reject(error ?? new Error("无法分配端口"))
|
||||
@@ -72,7 +71,7 @@ async function freePort() {
|
||||
|
||||
async function waitForHealth(origin) {
|
||||
let lastError;
|
||||
for (let attempt = 0; attempt < 45; attempt += 1) {
|
||||
for (let attempt = 0; attempt < 60; attempt += 1) {
|
||||
try {
|
||||
const response = await fetch(`${origin}/api/health`);
|
||||
if (response.ok && (await response.json()).status === "ok") {
|
||||
@@ -88,19 +87,14 @@ async function waitForHealth(origin) {
|
||||
);
|
||||
}
|
||||
|
||||
function inspectFontPackMount(containerId, expectedRoot) {
|
||||
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, "容器缺少字体包挂载");
|
||||
assert(mount.RW === false, "字体包挂载不是只读");
|
||||
assert(
|
||||
path.resolve(mount.Source) === path.resolve(expectedRoot),
|
||||
`字体包挂载源不匹配:${mount.Source}`
|
||||
);
|
||||
assert(!mount, "Docker 字体包必须来自镜像层,不能依赖运行时挂载");
|
||||
}
|
||||
|
||||
async function exportDocx(origin) {
|
||||
@@ -109,8 +103,8 @@ async function exportDocx(origin) {
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
markdown:
|
||||
"---\ntitle: Docker 字体包验收\n---\n\n# Docker 字体包验收\n\n这是一段可编辑中文正文。",
|
||||
fileName: "Docker 字体包验收.md",
|
||||
"---\ntitle: Docker 内置字体包验收\n---\n\n# Docker 内置字体包验收\n\n这是一段可编辑中文正文。",
|
||||
fileName: "Docker 内置字体包验收.md",
|
||||
language: "zh-CN",
|
||||
resources: [],
|
||||
exportConfig: {
|
||||
@@ -127,194 +121,137 @@ async function exportDocx(origin) {
|
||||
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
|
||||
);
|
||||
const fontPackStatus = await stat(fontPackRoot).catch(() => undefined);
|
||||
assert(
|
||||
validRootStatus?.isDirectory(),
|
||||
`未找到有效字体包根目录:${validFontPackRoot}。请先运行 npm run build:font-pack。`
|
||||
fontPackStatus?.isDirectory(),
|
||||
`未找到有效字体包根目录:${fontPackRoot}。请先运行 npm run build:font-pack。`
|
||||
);
|
||||
const localManifest = await readFile(manifestPath);
|
||||
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 });
|
||||
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-")
|
||||
const embeddedManifestHash = run(
|
||||
"docker",
|
||||
[
|
||||
"run",
|
||||
"--rm",
|
||||
"--entrypoint",
|
||||
"sha256sum",
|
||||
image,
|
||||
"/app/.local/font-packs/mdtp-serif-sc/1.0.0/font-pack.json"
|
||||
],
|
||||
{ capture: true }
|
||||
).split(/\s+/u)[0];
|
||||
assert(
|
||||
embeddedManifestHash === sha256(localManifest),
|
||||
"镜像内字体包清单与本次受校验构建产物不一致"
|
||||
);
|
||||
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
|
||||
}
|
||||
})
|
||||
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.0"
|
||||
};
|
||||
const compose = [
|
||||
"compose",
|
||||
"--project-name",
|
||||
projectName,
|
||||
"--file",
|
||||
composeFile
|
||||
];
|
||||
|
||||
try {
|
||||
run(
|
||||
"docker",
|
||||
[...compose, "up", "--detach", "--no-build", "--pull", "never"],
|
||||
{ env: environment }
|
||||
);
|
||||
results.push(
|
||||
await runScenario({
|
||||
name: "valid",
|
||||
root: validFontPackRoot,
|
||||
expected: {
|
||||
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:mdtp-serif-sc@1.0.0"),
|
||||
"Docker 内置字体包未进入主题 CSS"
|
||||
);
|
||||
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 字体表");
|
||||
|
||||
process.stdout.write(
|
||||
`${JSON.stringify(
|
||||
{
|
||||
image,
|
||||
embeddedManifestSha256: embeddedManifestHash,
|
||||
runtimeFontPackMount: false,
|
||||
fontPackAvailable: true,
|
||||
exportDocx: true,
|
||||
minimumFontParts: 2
|
||||
}
|
||||
})
|
||||
pandocVersion: capability.detectedVersion,
|
||||
docxFontParts: fontParts.length
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`
|
||||
);
|
||||
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 {
|
||||
run(
|
||||
"docker",
|
||||
[...compose, "down", "--remove-orphans", "--timeout", "5"],
|
||||
{ env: environment }
|
||||
);
|
||||
await rm(temporaryRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user