From 3081c028870f326ad16b32862981801e033ae491 Mon Sep 17 00:00:00 2001 From: SkyJourney Date: Sat, 1 Aug 2026 08:56:20 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=20Docker=20DOCX=20?= =?UTF-8?q?=E4=B8=8E=E5=8F=AF=E9=80=89=E5=AD=97=E4=BD=93=E5=8C=85=E9=83=A8?= =?UTF-8?q?=E7=BD=B2=E9=93=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/server/tests/docker-deployment.test.ts | 92 ++++++ deploy/Dockerfile | 59 ++++ deploy/README.md | 63 +++- deploy/compose.yaml | 3 + deploy/verify-font-pack-compose.mjs | 320 ++++++++++++++++++++ docs/PANDOC_DISTRIBUTION.md | 16 +- docs/PROGRESS.md | 32 +- package.json | 1 + 8 files changed, 577 insertions(+), 9 deletions(-) create mode 100644 apps/server/tests/docker-deployment.test.ts create mode 100644 deploy/verify-font-pack-compose.mjs diff --git a/apps/server/tests/docker-deployment.test.ts b/apps/server/tests/docker-deployment.test.ts new file mode 100644 index 0000000..42e6440 --- /dev/null +++ b/apps/server/tests/docker-deployment.test.ts @@ -0,0 +1,92 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +interface PackageManifest { + scripts?: Record; +} + +const projectRoot = fileURLToPath( + new URL("../../../", import.meta.url) +); + +function readProjectFile(relativePath: string) { + return readFileSync(`${projectRoot}/${relativePath}`, "utf8"); +} + +describe("Docker v0.6.0 部署契约", () => { + it("完整构建并复制 DOCX 运行时工作区和 Lua Filter", () => { + const dockerfile = readProjectFile("deploy/Dockerfile"); + const workspaces = [ + "semantic-document", + "docx-theme-engine", + "font-pack-registry", + "docx-engine" + ]; + + for (const workspace of workspaces) { + expect(dockerfile).toContain( + `COPY packages/${workspace}/package.json packages/${workspace}/package.json` + ); + expect(dockerfile).toContain( + `npm run build -w @md-to-pdf/${workspace}` + ); + expect(dockerfile).toContain( + `/app/packages/${workspace}/dist ./packages/${workspace}/dist` + ); + } + expect(dockerfile).toContain( + "/app/packages/docx-engine/assets ./packages/docx-engine/assets" + ); + }); + + it("固定校验 Pandoc 二进制、许可证、版权和最终版本", () => { + const dockerfile = readProjectFile("deploy/Dockerfile"); + + expect(dockerfile).toContain("ARG PANDOC_VERSION=3.9.0.2"); + expect(dockerfile).toContain( + "A69ABFABABDA8A56969A254B09F9553A7BE89DDEC00D4E0FE9FD585D71A67508" + ); + expect(dockerfile).toContain( + "9D56CAC92294E206AF026A5502BEE0FED77200B08B51EC28AA63C9EFDA4DCFDD" + ); + expect(dockerfile).toContain( + "842E33EF01625E93F85BEBB8BAC83AA570186B7AA77A09971257CC29F8F60740" + ); + expect(dockerfile).toContain( + "COPY --from=pandoc-runtime /opt/pandoc/3.9.0.2 /opt/pandoc/3.9.0.2" + ); + expect(dockerfile).toContain( + 'test "$(pandoc --version | head -n 1)" = "pandoc 3.9.0.2"' + ); + }); + + it("只读挂载字体包并排除本地字体资产构建上下文", () => { + const compose = readProjectFile("deploy/compose.yaml"); + const dockerignore = readProjectFile( + "deploy/Dockerfile.dockerignore" + ); + + expect(compose).toContain( + "FONT_PACK_ROOT: /app/.local/font-packs" + ); + expect(compose).toContain( + "${MD_TO_PDF_FONT_PACK_DIR:-../.local/font-packs}:/app/.local/font-packs:ro" + ); + expect(compose).toContain( + 'APP_VERSION: "${MD_TO_PDF_APP_VERSION:-0.6.0}"' + ); + expect(dockerignore.split(/\r?\n/u)).toContain(".local"); + expect(dockerignore.split(/\r?\n/u)).toContain("output"); + }); + + it("提供显式真实 Docker 字体包验收命令", () => { + const manifest = JSON.parse( + readProjectFile("package.json") + ) as PackageManifest; + + expect(manifest.scripts?.["verify:docker-font-pack"]).toContain( + "deploy/verify-font-pack-compose.mjs" + ); + }); +}); diff --git a/deploy/Dockerfile b/deploy/Dockerfile index cf32aef..6f0047d 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -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 diff --git a/deploy/README.md b/deploy/README.md index c08669b..88e5b24 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -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 构建上下文忽略的目录,既不提交到 +仓库,也不打入应用镜像。更换字体包版本后重启容器即可重新发现;不要在 +同一个 `/` 目录中覆盖不同内容。 + +发布前可以对已经构建好的镜像执行真实字体包矩阵: + +```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 校验。 diff --git a/deploy/compose.yaml b/deploy/compose.yaml index adeba04..c621609 100644 --- a/deploy/compose.yaml +++ b/deploy/compose.yaml @@ -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 diff --git a/deploy/verify-font-pack-compose.mjs b/deploy/verify-font-pack-compose.mjs new file mode 100644 index 0000000..c01a023 --- /dev/null +++ b/deploy/verify-font-pack-compose.mjs @@ -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 }); +} diff --git a/docs/PANDOC_DISTRIBUTION.md b/docs/PANDOC_DISTRIBUTION.md index 2d2b966..bf68a4c 100644 --- a/docs/PANDOC_DISTRIBUTION.md +++ b/docs/PANDOC_DISTRIBUTION.md @@ -71,8 +71,20 @@ Docker 安装目标: /usr/local/bin/pandoc -> /opt/pandoc/3.9.0.2/bin/pandoc ``` -构建必须校验 SHA-256 后再解压。下载、校验和解压应位于同一构建层,不在 -最终镜像保留下载缓存。 +官方 Linux 二进制 tarball 实际只包含 `bin/pandoc`,不包含许可证和版权 +文件。Docker 构建会从同一个不可变版本标签 `3.9.0.2` 取得以下文件,并 +分别执行 SHA-256 校验后放入相同运行目录: + +```text +COPYING.md +SHA-256:9D56CAC92294E206AF026A5502BEE0FED77200B08B51EC28AA63C9EFDA4DCFDD + +COPYRIGHT +SHA-256:842E33EF01625E93F85BEBB8BAC83AA570186B7AA77A09971257CC29F8F60740 +``` + +二进制归档、许可证和版权文件均在独立下载阶段获取与校验,最终镜像只 +复制 `/opt/pandoc/3.9.0.2/`,不保留下载缓存、Curl 或下载阶段依赖。 `v0.6.0` 正式验证范围为 Linux amd64。官方同时提供 arm64 包,但在完成 Playwright、Pandoc 和 DOCX 全链路验证前,不宣称 arm64 发布支持。 diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 7efdec8..86d70e2 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -362,6 +362,32 @@ Desktop 已统一 Markdown 保存/另存为、PDF 导出和 DOCX 导出的默认 阻断保存对话框。状态持久化失败不会把已经成功写入的用户文件误报为保存 失败。该优化作为独立提交完成,不与 FP3-C Docker 部署改动混合。 +`v0.5.1` Web 与 Desktop 旧渲染引擎曾在整篇代码围栏全部标记为 JSON 时 +出现缩进视觉丢失;任一围栏改为其他语言后其余 JSON 又会恢复。使用原始 +问题文档在当前 `v0.6.0` Chromium 快速预览中验证,首次完整渲染的 14 个 +JSON 代码块均保留 2/4/6 级缩进,将首个围栏改为 `text` 后的增量渲染也 +保持正常。该问题标记为随 `v0.6.0` 渲染引擎重构消失,不再追加生产修复。 + +阶段 12D-FP3-C 已完成 Docker v0.6.0 DOCX 运行链和可选字体包挂载。镜像 +构建补齐统一语义文档、DOCX 主题映射、字体包注册器和 DOCX 引擎工作区, +并将 Lua Filter 作为明确运行资产复制;独立下载阶段对 Pandoc `3.9.0.2` +Linux amd64 二进制、GPL 许可证和版权文件分别执行固定 SHA-256 校验, +最终运行镜像只保留 `/opt/pandoc/3.9.0.2/`,不包含下载工具或可选字体包。 +基于已验证 Chromium 运行层生成的验收镜像内容大小为 681,961,183 字节, +以非 root `pwuser` 运行,DOCX capability 精确识别 Pandoc `3.9.0.2`。 + +Compose 通过 `MD_TO_PDF_FONT_PACK_DIR` 将宿主机字体包根目录只读挂载到 +`/app/.local/font-packs`,并以 `MD_TO_PDF_APP_VERSION=0.6.0` 执行兼容性 +判断。静态部署契约覆盖 DOCX workspace、Pandoc 版本与三组哈希、Lua +Filter、只读挂载以及 Docker 构建上下文排除。统一真实门禁为 +`npm run verify:docker-font-pack`:空目录场景正常降级;有效 +`mdtp-serif-sc@1.0.0` 提供不可变缓存 WOFF2,并进入真实 DOCX 字体表和 +字体部件;篡改 WOFF2 后注册器拒绝整个字体包,主题 CSS 不再注入字体包, +但 DOCX 仍通过主题字体安全降级并成功生成。三组隔离 Compose 容器、网络 +和临时目录均在验收后清理。全仓 94 个测试文件、480 项测试、类型检查、 +生产构建和 `git diff --check` 均通过。下一步 R4 进入 14 套主题深度视觉 +回归。 + ## 2. 已完成 ### 2.1 项目骨架 @@ -1320,8 +1346,10 @@ ECharts 第二阶段浏览器与 PDF 验证结果: - 阶段 12D-FP3-A:已完成独立字体包构建器、冻结配方、许可证与来源追踪、 同版本不可变门禁和真实资产构建; - 阶段 12D-FP3-B:已完成独立用户级 NSIS 字体包、原子安装/升级/卸载、 - 主安装器同目录哈希校验和显式静默开关;下一步 FP3-C 实现 Docker 可选 - 挂载,最终由 R4 完成 14 套主题深度视觉回归; + 主安装器同目录哈希校验和显式静默开关; +- 阶段 12D-FP3-C:已完成 Docker v0.6.0 DOCX/Pandoc 运行链、字体包只读 + 可选挂载及空目录、有效包、损坏包三场景真实部署门禁;下一步由 R4 完成 + 14 套主题深度视觉回归; - 阶段 13:完成 Word/WPS 双向互存、外部主题兼容、体积和正式发布验收。 每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入 diff --git a/package.json b/package.json index bce46d3..662f46e 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "desktop:package": "npm run package -w @md-to-pdf/desktop", "build:font-pack": "npm run build -w @md-to-pdf/core && 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/font-pack-builder && node packages/font-pack-builder/dist/cli.js build", "verify:font-pack": "npm run build -w @md-to-pdf/core && 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/font-pack-builder && node packages/font-pack-builder/dist/cli.js verify", + "verify:docker-font-pack": "npm run verify:font-pack && npm run build:web-runtime && npm run build -w @md-to-pdf/server && node deploy/verify-font-pack-compose.mjs", "build:font-pack:windows": "npm run verify:font-pack && node packages/font-pack-builder/dist/cli.js windows-installer", "desktop:make:with-font-pack": "npm run build:font-pack:windows && node scripts/build-desktop-with-font-pack.mjs", "theme:import-typora": "node scripts/import-typora-theme.mjs",