feat: 完善字体包发行与 Docker 内置字体

This commit is contained in:
SkyJourney
2026-08-02 11:33:27 +08:00
parent 3a8d2c1c2e
commit 14f470454c
19 changed files with 434 additions and 252 deletions
+6 -2
View File
@@ -21,7 +21,7 @@ COPY packages/preview-engine/package.json packages/preview-engine/package.json
COPY packages/renderer/package.json packages/renderer/package.json
RUN npm ci
COPY tsconfig.base.json ./
COPY tsconfig.base.json product.json ./
COPY apps ./apps
COPY packages ./packages
COPY logos ./logos
@@ -151,6 +151,7 @@ COPY --chown=pwuser:pwuser --from=build /app/packages/preview-engine/dist ./pack
COPY --chown=pwuser:pwuser --from=build /app/packages/renderer/dist ./packages/renderer/dist
COPY --chown=pwuser:pwuser --from=build /app/samples ./samples
COPY --chown=pwuser:pwuser themes ./themes
COPY --chown=pwuser:pwuser output/font-packs/root /app/.local/font-packs
COPY deploy/nginx.conf /etc/nginx/nginx.conf
COPY --chown=pwuser:pwuser deploy/entrypoint.sh /app/deploy/entrypoint.sh
@@ -161,7 +162,10 @@ ENV HOST=127.0.0.1 \
PDF_RENDER_ORIGIN=http://127.0.0.1:8080 \
PDF_CONCURRENCY=1 \
PDF_MAX_QUEUE=4 \
PDF_TIMEOUT_MS=120000
PDF_TIMEOUT_MS=120000 \
FONT_PACK_ROOT=/app/.local/font-packs
RUN test -f /app/.local/font-packs/mdtp-serif-sc/1.0.0/font-pack.json
USER pwuser
+5 -1
View File
@@ -7,7 +7,11 @@ coverage
dist
node_modules
out
output
output/*
!output/font-packs/
output/font-packs/*
!output/font-packs/root/
!output/font-packs/root/**
playwright-report
test-results
tmp
+14 -22
View File
@@ -74,11 +74,11 @@ docker compose -f deploy\compose.yaml up -d
3 套标书主题;挂载目录只用于额外的本机扩展主题,无需重复
放置内置主题。内置主题与挂载主题 ID 重复时以内置版本为准。
## 可选字体包挂载
## 内置字体包
Compose 默认将项目的 `.local/font-packs` 只读挂载到容器中的
`/app/.local/font-packs`目录为空或不存在字体包时,应用仍可正常启动,
Preview、PDF 和 DOCX 会使用主题原有字体安全降级
v0.6.0 的发行镜像固定将受校验字体包内置到
`/app/.local/font-packs`Compose 不挂载宿主机字体目录,因此部署端
无需额外复制字体,Preview、PDF 和 DOCX 可以离线使用同一套原生资产
字体包使用固定目录结构:
@@ -91,36 +91,29 @@ Preview、PDF 和 DOCX 会使用主题原有字体安全降级。
└── fonts/
```
仓库外准备好冻结字体源后,可以构建并挂载项目字体包
源码构建镜像前,先从仓库外冻结源生成字体包,再构建镜像
```powershell
npm run build:font-pack
$env:MD_TO_PDF_FONT_PACK_DIR = (Resolve-Path 'output\font-packs\root').Path
docker compose -f deploy\compose.yaml up -d
```
也可以将完整字体包根目录复制到 `.local/font-packs`,直接使用默认配置。
字体包目录以 `:ro` 挂载,容器内非 root 用户不能修改宿主机资产;清单、
许可、文件类型、路径边界、容量和 SHA-256 仍会由运行时注册器再次校验。
字体包缺失、版本不兼容或资源损坏不会阻断 PDF/DOCX 主链,只会产生诊断
并回退到主题字体。
字体二进制仍不提交到 Git。Docker 构建上下文只放行本次生成的
`output/font-packs/root`,镜像构建会在缺少清单时立即失败。运行时注册器
仍会复核许可、文件类型、路径边界、容量和 SHA-256。更换字体包内容时
必须提升字体包 SemVer 并重新构建镜像,不能覆盖同一个版本。
可选字体包二进制位于被 Git 和 Docker 构建上下文忽略的目录,既不提交到
仓库,也不打入应用镜像。更换字体包版本后重启容器即可重新发现;不要在
同一个 `<pack-id>/<semver>` 目录中覆盖不同内容。
发布前可以对已经构建好的镜像执行真实字体包矩阵:
发布前执行真实内置字体门禁:
```powershell
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.6.0'
$env:MD_TO_PDF_FONT_PACK_DIR = (Resolve-Path 'output\font-packs\root').Path
npm run verify:docker-font-pack
```
该命令要求本机 Docker 引擎已经启动,镜像已经存在,并且字体包已通过
`npm run build:font-pack` 生成。验收会依次挂载空目录、有效字体包和篡改
字体包,检查只读挂载、健康状态、Pandoc capability、WOFF2 资源、DOCX
字体部件和损坏后的安全降级;所有临时容器、网络和目录会在结束时清理。
该命令要求本机 Docker 引擎已经启动,并且冻结字体源可供
`npm run build:font-pack` 生成。验收会重建指定镜像,核对镜像内清单哈希、
确认不存在字体运行时挂载,并检查健康状态、Pandoc capability、WOFF2
资源和 DOCX 字体部件;所有临时容器、网络和目录会在结束时清理。
## 运行参数
@@ -131,7 +124,6 @@ npm run verify:docker-font-pack
| `MD_TO_PDF_REUSE_PLAYWRIGHT_RUNTIME` | `0` | 是否复用基础镜像中的 Chromium |
| `MD_TO_PDF_PORT` | `8080` | 宿主机监听端口 |
| `MD_TO_PDF_THEME_DIR` | `../.local/themes` | 宿主机主题目录 |
| `MD_TO_PDF_FONT_PACK_DIR` | `../.local/font-packs` | 宿主机可选字体包根目录 |
| `MD_TO_PDF_APP_VERSION` | `0.6.0` | 字体包兼容性判断使用的应用版本 |
| `PDF_CONCURRENCY` | `1` | 同时执行的 PDF 任务数 |
| `PDF_MAX_QUEUE` | `4` | 等待队列上限 |
-1
View File
@@ -23,7 +23,6 @@ services:
FONT_PACK_ROOT: /app/.local/font-packs
volumes:
- "${MD_TO_PDF_THEME_DIR:-../.local/themes}:/app/.local/themes:ro"
- "${MD_TO_PDF_FONT_PACK_DIR:-../.local/font-packs}:/app/.local/font-packs:ro"
shm_size: "1gb"
security_opt:
- no-new-privileges:true
+143 -206
View File
@@ -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 });
}