feat: 完成 Docker DOCX 与可选字体包部署链
This commit is contained in:
@@ -11,6 +11,10 @@ COPY package.json package-lock.json ./
|
||||
COPY apps/server/package.json apps/server/package.json
|
||||
COPY apps/web/package.json apps/web/package.json
|
||||
COPY packages/core/package.json packages/core/package.json
|
||||
COPY packages/semantic-document/package.json packages/semantic-document/package.json
|
||||
COPY packages/docx-theme-engine/package.json packages/docx-theme-engine/package.json
|
||||
COPY packages/font-pack-registry/package.json packages/font-pack-registry/package.json
|
||||
COPY packages/docx-engine/package.json packages/docx-engine/package.json
|
||||
COPY packages/application/package.json packages/application/package.json
|
||||
COPY packages/markdown-echarts/package.json packages/markdown-echarts/package.json
|
||||
COPY packages/preview-engine/package.json packages/preview-engine/package.json
|
||||
@@ -24,6 +28,10 @@ COPY logos ./logos
|
||||
COPY samples ./samples
|
||||
RUN npm run build -w @md-to-pdf/markdown-echarts \
|
||||
&& npm run build -w @md-to-pdf/core \
|
||||
&& npm run build -w @md-to-pdf/semantic-document \
|
||||
&& npm run build -w @md-to-pdf/docx-theme-engine \
|
||||
&& npm run build -w @md-to-pdf/font-pack-registry \
|
||||
&& npm run build -w @md-to-pdf/docx-engine \
|
||||
&& npm run build -w @md-to-pdf/renderer \
|
||||
&& npm run build -w @md-to-pdf/application \
|
||||
&& npm run build -w @md-to-pdf/preview-engine \
|
||||
@@ -39,12 +47,50 @@ COPY package.json package-lock.json ./
|
||||
COPY apps/server/package.json apps/server/package.json
|
||||
COPY apps/web/package.json apps/web/package.json
|
||||
COPY packages/core/package.json packages/core/package.json
|
||||
COPY packages/semantic-document/package.json packages/semantic-document/package.json
|
||||
COPY packages/docx-theme-engine/package.json packages/docx-theme-engine/package.json
|
||||
COPY packages/font-pack-registry/package.json packages/font-pack-registry/package.json
|
||||
COPY packages/docx-engine/package.json packages/docx-engine/package.json
|
||||
COPY packages/application/package.json packages/application/package.json
|
||||
COPY packages/markdown-echarts/package.json packages/markdown-echarts/package.json
|
||||
COPY packages/preview-engine/package.json packages/preview-engine/package.json
|
||||
COPY packages/renderer/package.json packages/renderer/package.json
|
||||
RUN npm ci --omit=dev --workspace @md-to-pdf/server --include-workspace-root
|
||||
|
||||
FROM node:22-bookworm-slim AS pandoc-runtime
|
||||
|
||||
ARG PANDOC_VERSION=3.9.0.2
|
||||
ARG PANDOC_LINUX_AMD64_URL=https://github.com/jgm/pandoc/releases/download/3.9.0.2/pandoc-3.9.0.2-linux-amd64.tar.gz
|
||||
ARG PANDOC_LINUX_AMD64_SHA256=A69ABFABABDA8A56969A254B09F9553A7BE89DDEC00D4E0FE9FD585D71A67508
|
||||
ARG PANDOC_COPYING_URL=https://raw.githubusercontent.com/jgm/pandoc/3.9.0.2/COPYING.md
|
||||
ARG PANDOC_COPYING_SHA256=9D56CAC92294E206AF026A5502BEE0FED77200B08B51EC28AA63C9EFDA4DCFDD
|
||||
ARG PANDOC_COPYRIGHT_URL=https://raw.githubusercontent.com/jgm/pandoc/3.9.0.2/COPYRIGHT
|
||||
ARG PANDOC_COPYRIGHT_SHA256=842E33EF01625E93F85BEBB8BAC83AA570186B7AA77A09971257CC29F8F60740
|
||||
RUN test "$(dpkg --print-architecture)" = "amd64" \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN --mount=type=cache,target=/var/cache/pandoc \
|
||||
set -eux; \
|
||||
archive="/var/cache/pandoc/pandoc-${PANDOC_VERSION}-linux-amd64.tar.gz"; \
|
||||
if [ ! -f "${archive}" ]; then \
|
||||
curl --fail --location --retry 3 --silent --show-error \
|
||||
--output "${archive}" "${PANDOC_LINUX_AMD64_URL}"; \
|
||||
fi; \
|
||||
echo "${PANDOC_LINUX_AMD64_SHA256} ${archive}" | sha256sum --check --strict; \
|
||||
mkdir -p "/opt/pandoc/${PANDOC_VERSION}"; \
|
||||
tar --extract --gzip --file "${archive}" \
|
||||
--directory "/opt/pandoc/${PANDOC_VERSION}" --strip-components=1; \
|
||||
curl --fail --location --retry 3 --silent --show-error \
|
||||
--output "/opt/pandoc/${PANDOC_VERSION}/COPYING.md" "${PANDOC_COPYING_URL}"; \
|
||||
echo "${PANDOC_COPYING_SHA256} /opt/pandoc/${PANDOC_VERSION}/COPYING.md" \
|
||||
| sha256sum --check --strict; \
|
||||
curl --fail --location --retry 3 --silent --show-error \
|
||||
--output "/opt/pandoc/${PANDOC_VERSION}/COPYRIGHT" "${PANDOC_COPYRIGHT_URL}"; \
|
||||
echo "${PANDOC_COPYRIGHT_SHA256} /opt/pandoc/${PANDOC_VERSION}/COPYRIGHT" \
|
||||
| sha256sum --check --strict; \
|
||||
test "$(/opt/pandoc/${PANDOC_VERSION}/bin/pandoc --version | head -n 1)" = "pandoc ${PANDOC_VERSION}"
|
||||
|
||||
FROM ${RUNTIME_BASE_IMAGE} AS runtime
|
||||
|
||||
USER root
|
||||
@@ -63,12 +109,20 @@ RUN if [ "${REUSE_PLAYWRIGHT_RUNTIME}" = "1" ]; then \
|
||||
&& useradd --system --create-home --gid pwuser --shell /bin/bash pwuser; \
|
||||
fi
|
||||
|
||||
COPY --from=pandoc-runtime /opt/pandoc/3.9.0.2 /opt/pandoc/3.9.0.2
|
||||
RUN ln -s /opt/pandoc/3.9.0.2/bin/pandoc /usr/local/bin/pandoc \
|
||||
&& test "$(pandoc --version | head -n 1)" = "pandoc 3.9.0.2"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --chown=pwuser:pwuser package.json package-lock.json ./
|
||||
COPY --chown=pwuser:pwuser apps/server/package.json apps/server/package.json
|
||||
COPY --chown=pwuser:pwuser apps/web/package.json apps/web/package.json
|
||||
COPY --chown=pwuser:pwuser packages/core/package.json packages/core/package.json
|
||||
COPY --chown=pwuser:pwuser packages/semantic-document/package.json packages/semantic-document/package.json
|
||||
COPY --chown=pwuser:pwuser packages/docx-theme-engine/package.json packages/docx-theme-engine/package.json
|
||||
COPY --chown=pwuser:pwuser packages/font-pack-registry/package.json packages/font-pack-registry/package.json
|
||||
COPY --chown=pwuser:pwuser packages/docx-engine/package.json packages/docx-engine/package.json
|
||||
COPY --chown=pwuser:pwuser packages/application/package.json packages/application/package.json
|
||||
COPY --chown=pwuser:pwuser packages/markdown-echarts/package.json packages/markdown-echarts/package.json
|
||||
COPY --chown=pwuser:pwuser packages/preview-engine/package.json packages/preview-engine/package.json
|
||||
@@ -86,6 +140,11 @@ RUN if [ "${REUSE_PLAYWRIGHT_RUNTIME}" = "1" ]; then \
|
||||
COPY --chown=pwuser:pwuser --from=build /app/apps/server/dist ./apps/server/dist
|
||||
COPY --chown=pwuser:pwuser --from=build /app/apps/web/dist ./apps/web/dist
|
||||
COPY --chown=pwuser:pwuser --from=build /app/packages/core/dist ./packages/core/dist
|
||||
COPY --chown=pwuser:pwuser --from=build /app/packages/semantic-document/dist ./packages/semantic-document/dist
|
||||
COPY --chown=pwuser:pwuser --from=build /app/packages/docx-theme-engine/dist ./packages/docx-theme-engine/dist
|
||||
COPY --chown=pwuser:pwuser --from=build /app/packages/font-pack-registry/dist ./packages/font-pack-registry/dist
|
||||
COPY --chown=pwuser:pwuser --from=build /app/packages/docx-engine/dist ./packages/docx-engine/dist
|
||||
COPY --chown=pwuser:pwuser --from=build /app/packages/docx-engine/assets ./packages/docx-engine/assets
|
||||
COPY --chown=pwuser:pwuser --from=build /app/packages/application/dist ./packages/application/dist
|
||||
COPY --chown=pwuser:pwuser --from=build /app/packages/markdown-echarts/dist ./packages/markdown-echarts/dist
|
||||
COPY --chown=pwuser:pwuser --from=build /app/packages/preview-engine/dist ./packages/preview-engine/dist
|
||||
|
||||
+58
-5
@@ -2,7 +2,8 @@
|
||||
|
||||
本目录提供单容器部署:Nginx 在 `8080` 端口提供前端,并将
|
||||
`/api/` 代理到同容器内的 Fastify;Fastify 复用固定版本
|
||||
Playwright Chromium 生成 PDF。
|
||||
Playwright Chromium 生成 PDF,并使用固定 Pandoc `3.9.0.2` 生成
|
||||
可编辑 DOCX。可选字体包只从宿主机只读挂载,不进入应用镜像。
|
||||
|
||||
## 启动
|
||||
|
||||
@@ -37,7 +38,7 @@ docker compose -f deploy\compose.yaml down
|
||||
可以直接指定版本化镜像名称构建:
|
||||
|
||||
```powershell
|
||||
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.5.1'
|
||||
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.6.0'
|
||||
docker compose -f deploy\compose.yaml build
|
||||
```
|
||||
|
||||
@@ -47,13 +48,13 @@ docker compose -f deploy\compose.yaml build
|
||||
已经保留同版本系列的已验证镜像,可在内网或软件源较慢时复用其运行层:
|
||||
|
||||
```powershell
|
||||
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.5.1'
|
||||
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.6.0'
|
||||
$env:MD_TO_PDF_RUNTIME_BASE_IMAGE = 'yixiong/md-to-pdf:v0.4.5'
|
||||
$env:MD_TO_PDF_REUSE_PLAYWRIGHT_RUNTIME = '1'
|
||||
docker compose -f deploy\compose.yaml build
|
||||
```
|
||||
|
||||
复用模式会校验基础镜像中存在 Chromium,再覆盖 v0.5.1 应用代码和生产
|
||||
复用模式会校验基础镜像中存在 Chromium,再覆盖 v0.6.0 应用代码和生产
|
||||
依赖。正式跨机器构建仍建议使用默认完整路径。
|
||||
|
||||
## 主题挂载
|
||||
@@ -73,6 +74,54 @@ docker compose -f deploy\compose.yaml up -d
|
||||
3 套标书主题;挂载目录只用于额外的本机扩展主题,无需重复
|
||||
放置内置主题。内置主题与挂载主题 ID 重复时以内置版本为准。
|
||||
|
||||
## 可选字体包挂载
|
||||
|
||||
Compose 默认将项目的 `.local/font-packs` 只读挂载到容器中的
|
||||
`/app/.local/font-packs`。目录为空或不存在字体包时,应用仍可正常启动,
|
||||
Preview、PDF 和 DOCX 会使用主题原有字体安全降级。
|
||||
|
||||
字体包使用固定目录结构:
|
||||
|
||||
```text
|
||||
<字体包根目录>/
|
||||
└── mdtp-serif-sc/
|
||||
└── 1.0.0/
|
||||
├── font-pack.json
|
||||
├── LICENSE.txt
|
||||
└── 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 构建上下文忽略的目录,既不提交到
|
||||
仓库,也不打入应用镜像。更换字体包版本后重启容器即可重新发现;不要在
|
||||
同一个 `<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
|
||||
字体部件和损坏后的安全降级;所有临时容器、网络和目录会在结束时清理。
|
||||
|
||||
## 运行参数
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
@@ -82,6 +131,8 @@ docker compose -f deploy\compose.yaml up -d
|
||||
| `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` | 等待队列上限 |
|
||||
| `PDF_TIMEOUT_MS` | `120000` | 单次 PDF 生成超时 |
|
||||
@@ -91,4 +142,6 @@ docker compose -f deploy\compose.yaml up -d
|
||||
|
||||
容器为 Chromium 提供 1 GiB 共享内存,并以非 root `pwuser` 运行。
|
||||
构建阶段使用项目锁定的 Playwright `1.62.0` 安装精确匹配的 Chromium
|
||||
及系统依赖,不依赖版本可能滞后的预制浏览器镜像标签。
|
||||
及系统依赖,不依赖版本可能滞后的预制浏览器镜像标签。DOCX 运行时仅
|
||||
支持 Linux amd64,Pandoc 二进制、许可证和版权文件均在构建阶段执行
|
||||
固定版本与 SHA-256 校验。
|
||||
|
||||
@@ -19,8 +19,11 @@ services:
|
||||
PDF_CONCURRENCY: "${PDF_CONCURRENCY:-1}"
|
||||
PDF_MAX_QUEUE: "${PDF_MAX_QUEUE:-4}"
|
||||
PDF_TIMEOUT_MS: "${PDF_TIMEOUT_MS:-120000}"
|
||||
APP_VERSION: "${MD_TO_PDF_APP_VERSION:-0.6.0}"
|
||||
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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
Reference in New Issue
Block a user