From 9f96d5192262cc665bf42fffaa7ababa197ba2b5 Mon Sep 17 00:00:00 2001 From: SkyJourney Date: Fri, 31 Jul 2026 21:15:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=E5=8F=AF=E9=80=89?= =?UTF-8?q?=E5=AD=97=E4=BD=93=E5=8C=85=E5=90=8C=E6=BA=90=E6=B8=B2=E6=9F=93?= =?UTF-8?q?=E9=93=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/desktop/src/application-contract.ts | 30 +++ apps/desktop/src/main.ts | 65 ++++- apps/desktop/src/pdf-contract.ts | 5 +- apps/desktop/tests/pdf-contract.test.ts | 25 ++ apps/server/src/app.ts | 45 +++- apps/server/src/index.ts | 11 +- apps/server/tests/app.test.ts | 93 +++++++ docs/PROGRESS.md | 31 ++- package-lock.json | 1 + package.json | 11 +- packages/application/package.json | 1 + .../application/src/application-service.ts | 11 +- .../application/src/docx-export-service.ts | 5 +- packages/application/src/index.ts | 1 + packages/application/src/theme-registry.ts | 254 +++++++++++++++++- .../tests/application-service.test.ts | 151 +++++++++++ packages/core/src/theme.ts | 4 +- 17 files changed, 720 insertions(+), 24 deletions(-) diff --git a/apps/desktop/src/application-contract.ts b/apps/desktop/src/application-contract.ts index d26deb4..e579520 100644 --- a/apps/desktop/src/application-contract.ts +++ b/apps/desktop/src/application-contract.ts @@ -52,3 +52,33 @@ export function parseThemeResourceUrl(requestUrl: string) { assetPath: assetSegments.join("/") }; } + +export function parseFontPackResourceUrl(requestUrl: string) { + const url = new URL(requestUrl); + if (url.protocol !== "mdpdf:" || url.host !== "font-pack") { + return undefined; + } + let segments: string[]; + try { + segments = url.pathname + .split("/") + .filter(Boolean) + .map((segment) => decodeURIComponent(segment)); + } catch { + throw new Error("字体包资源地址无效"); + } + const [packId, packVersion, faceId, kind, ...extra] = segments; + if ( + !packId || + !/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(packId) || + !packVersion || + !/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u.test(packVersion) || + !faceId || + !/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(faceId) || + kind !== "web" || + extra.length > 0 + ) { + throw new Error("字体包资源地址无效"); + } + return { packId, packVersion, faceId, kind } as const; +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 0100e77..e2af05e 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -12,7 +12,10 @@ import { createApplicationService, type ApplicationService } from "@md-to-pdf/application"; -import { parseThemeResourceUrl } from "./application-contract.js"; +import { + parseFontPackResourceUrl, + parseThemeResourceUrl +} from "./application-contract.js"; import { DesktopApplicationController } from "./desktop-application-controller.js"; @@ -26,6 +29,7 @@ import { const APP_SCHEME = "mdpdf"; const APP_HOST = "bundle"; const THEME_HOST = "theme"; +const FONT_PACK_HOST = "font-pack"; const currentDirectory = path.dirname(fileURLToPath(import.meta.url)); let applicationController: DesktopApplicationController | undefined; let pendingExternalMarkdownPath = findMarkdownFileArgument( @@ -102,6 +106,21 @@ function getDesktopThemeDirectory() { : path.resolve(currentDirectory, "../../..", ".local", "themes"); } +function getDesktopFontPackDirectory() { + if (!app.isPackaged) { + return path.resolve( + currentDirectory, + "../../..", + ".local", + "font-packs" + ); + } + const localApplicationData = process.env.LOCALAPPDATA?.trim(); + return localApplicationData + ? path.join(localApplicationData, "md-to-pdf", "font-packs") + : path.join(app.getPath("userData"), "font-packs"); +} + function createDesktopApplicationService() { const projectRoot = path.resolve(currentDirectory, "../../.."); return createApplicationService({ @@ -111,6 +130,22 @@ function createDesktopApplicationService() { localRoot: getDesktopThemeDirectory(), createAssetUrl: (themeId, assetPath) => `${APP_SCHEME}://${THEME_HOST}/${encodeURIComponent(themeId)}/${encodeThemeAssetPath(assetPath)}`, + fontPacks: { + roots: [getDesktopFontPackDirectory()], + appVersion: app.getVersion(), + createAssetUrl: ( + packId, + version, + faceId, + kind, + sha256 + ) => + `${APP_SCHEME}://${FONT_PACK_HOST}/${encodeURIComponent( + packId + )}/${encodeURIComponent(version)}/${encodeURIComponent( + faceId + )}/${kind}?v=${sha256}` + }, onWarning: (message) => console.warn(message) }); } @@ -155,6 +190,34 @@ async function registerApplicationProtocol( return new Response("Not found", { status: 404 }); } } + if (url.host === FONT_PACK_HOST) { + try { + const resource = parseFontPackResourceUrl(request.url); + if (!resource) { + return new Response("Not found", { status: 404 }); + } + const asset = await applicationService.getFontPackAsset( + resource.packId, + resource.packVersion, + resource.faceId, + resource.kind + ); + if (!asset) { + return new Response("Not found", { status: 404 }); + } + return new Response(asset.content, { + headers: { + "access-control-allow-origin": "*", + "cache-control": "public, max-age=31536000, immutable", + "content-type": asset.contentType, + etag: `\"${asset.sha256}\"`, + "x-content-type-options": "nosniff" + } + }); + } catch { + return new Response("Not found", { status: 404 }); + } + } if (url.host !== APP_HOST) { return new Response("Not found", { status: 404 }); } diff --git a/apps/desktop/src/pdf-contract.ts b/apps/desktop/src/pdf-contract.ts index 7de4007..9c293dd 100644 --- a/apps/desktop/src/pdf-contract.ts +++ b/apps/desktop/src/pdf-contract.ts @@ -117,7 +117,10 @@ export function isAllowedPdfRuntimeUrl( if (["about:", "blob:", "data:"].includes(requested.protocol)) { return true; } - if (requested.protocol === "mdpdf:" && requested.host === "theme") { + if ( + requested.protocol === "mdpdf:" && + ["theme", "font-pack"].includes(requested.host) + ) { return true; } return requested.origin === new URL(renderUrl).origin; diff --git a/apps/desktop/tests/pdf-contract.test.ts b/apps/desktop/tests/pdf-contract.test.ts index 0c5af97..dfda5ba 100644 --- a/apps/desktop/tests/pdf-contract.test.ts +++ b/apps/desktop/tests/pdf-contract.test.ts @@ -10,6 +10,7 @@ import { } from "../src/pdf-contract.js"; import { parseMarkdownRenderRequest, + parseFontPackResourceUrl, parseThemeId, parseThemeResourceUrl } from "../src/application-contract.js"; @@ -105,6 +106,12 @@ describe("桌面 PDF 网络边界", () => { renderUrl ) ).toBe(true); + expect( + isAllowedPdfRuntimeUrl( + "mdpdf://font-pack/official-cjk/1.0.0/serif/web?v=abc", + renderUrl + ) + ).toBe(true); }); it("拒绝外部网络资源", () => { @@ -143,4 +150,22 @@ describe("桌面应用服务 IPC 载荷", () => { parseThemeResourceUrl("mdpdf://bundle/index.html") ).toBeUndefined(); }); + + it("只接受字体包 Web 资源地址", () => { + expect( + parseFontPackResourceUrl( + "mdpdf://font-pack/official-cjk/1.0.0/serif-regular/web?v=abc" + ) + ).toEqual({ + packId: "official-cjk", + packVersion: "1.0.0", + faceId: "serif-regular", + kind: "web" + }); + expect(() => + parseFontPackResourceUrl( + "mdpdf://font-pack/official-cjk/1.0.0/serif-regular/docx" + ) + ).toThrow("字体包资源地址无效"); + }); }); diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 83d7efd..f5047fe 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -17,7 +17,8 @@ import { DocxThemeTokenService, createApplicationService, readDocxExportRuntimeLimits, - type ApplicationService + type ApplicationService, + type ApplicationServiceOptions } from "@md-to-pdf/application"; import { PandocDocxConverter, @@ -88,6 +89,7 @@ export interface BuildAppOptions { "getCapability" | "generate" | "close" >; docxMediaAdapter?: ServerDocxMediaCaptureAdapter; + fontPacks?: NonNullable; } function milliseconds(value: number) { @@ -161,7 +163,8 @@ export function buildApp(options: BuildAppOptions = {}) { bundledRoot: resolve(projectRoot, "themes"), localRoot: options.localThemeRoot ?? resolve(projectRoot, ".local", "themes"), - onWarning: (message) => app.log.warn(message) + onWarning: (message) => app.log.warn(message), + ...(options.fontPacks ? { fontPacks: options.fontPacks } : {}) }); const pdfGenerator = options.pdfGenerator ?? createPdfGenerator(); @@ -301,6 +304,44 @@ export function buildApp(options: BuildAppOptions = {}) { } ); + app.get<{ + Params: { + packId: string; + packVersion: string; + faceId: string; + }; + }>( + "/api/font-packs/:packId/:packVersion/:faceId/web", + async (request, reply) => { + try { + const asset = await applicationService.getFontPackAsset( + request.params.packId, + request.params.packVersion, + request.params.faceId, + "web" + ); + if (!asset) { + return reply.code(404).send({ + error: "FONT_PACK_ASSET_NOT_FOUND", + message: "未找到指定字体包资源" + }); + } + return reply + .header("content-type", asset.contentType) + .header("cache-control", "public, max-age=31536000, immutable") + .header("content-security-policy", "default-src 'none'; sandbox") + .header("x-content-type-options", "nosniff") + .header("etag", `\"${asset.sha256}\"`) + .send(asset.content); + } catch { + return reply.code(404).send({ + error: "FONT_PACK_ASSET_NOT_FOUND", + message: "未找到指定字体包资源" + }); + } + } + ); + app.post<{ Body: RenderRequestBody }>( "/api/render", async (request, reply) => { diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index f254c65..0307e25 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -2,9 +2,18 @@ import { buildApp } from "./app.js"; const port = Number.parseInt(process.env.PORT ?? "3001", 10); const host = process.env.HOST ?? "0.0.0.0"; +const fontPackRoot = process.env.FONT_PACK_ROOT?.trim(); const app = buildApp({ prewarmPdfBrowser: true, - prewarmDocxRuntime: true + prewarmDocxRuntime: true, + ...(fontPackRoot + ? { + fontPacks: { + roots: [fontPackRoot], + appVersion: process.env.APP_VERSION ?? "0.6.0" + } + } + : {}) }); async function start() { diff --git a/apps/server/tests/app.test.ts b/apps/server/tests/app.test.ts index f732c2d..558d358 100644 --- a/apps/server/tests/app.test.ts +++ b/apps/server/tests/app.test.ts @@ -3,6 +3,7 @@ import type { FastifyInstance } from "fastify"; import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { createHash } from "node:crypto"; import { buildApp } from "../src/app.js"; import { createPdfContentDisposition, @@ -39,6 +40,61 @@ function localThemeManifest( }; } +function sha256(content: Uint8Array | string) { + return createHash("sha256").update(content).digest("hex"); +} + +async function createServerFontPack(root: string) { + const directory = join(root, "official-cjk", "1.0.0"); + const web = Buffer.from([31, 32, 33, 34]); + const docx = Buffer.from([41, 42, 43, 44, 45]); + const license = "SIL Open Font License 1.1"; + await mkdir(join(directory, "fonts"), { recursive: true }); + await writeFile(join(directory, "fonts", "serif.woff2"), web); + await writeFile(join(directory, "fonts", "serif.ttf"), docx); + await writeFile(join(directory, "OFL.txt"), license, "utf8"); + await writeFile( + join(directory, "font-pack.json"), + JSON.stringify({ + manifestVersion: 1, + id: "official-cjk", + version: "1.0.0", + name: "政务中文字体包", + description: "Server 字体包资源测试", + license: "OFL-1.1", + licenseFile: "OFL.txt", + licenseSha256: sha256(license), + licenseBytes: Buffer.byteLength(license), + compatibility: { + minimumAppVersion: "0.6.0", + maximumAppVersionExclusive: "0.7.0" + }, + faces: [ + { + id: "serif-regular", + targets: ["FandolSong", "Mdpdf Fandol Song"], + weight: 400, + style: "normal", + web: { + path: "fonts/serif.woff2", + format: "woff2", + bytes: web.byteLength, + sha256: sha256(web) + }, + docx: { + path: "fonts/serif.ttf", + format: "truetype", + bytes: docx.byteLength, + sha256: sha256(docx) + } + } + ] + }), + "utf8" + ); + return { web }; +} + afterEach(async () => { await app?.close(); if (temporaryDirectory) { @@ -136,6 +192,43 @@ describe("预览 API", () => { expect(response.body).toContain("#write"); }); + it("为主题 CSS 提供经过注册器验证的字体包 Web 资源", async () => { + temporaryDirectory = await mkdtemp(join(tmpdir(), "md-to-pdf-font-pack-")); + const fontPack = await createServerFontPack(temporaryDirectory); + app = buildApp({ + logger: false, + fontPacks: { + roots: [temporaryDirectory], + appVersion: "0.6.0" + } + }); + + const cssResponse = await app.inject({ + method: "GET", + url: "/api/themes/gov-red-standard/css" + }); + expect(cssResponse.statusCode).toBe(200); + expect(cssResponse.body).toContain("md-to-pdf-font-packs:official-cjk@1.0.0"); + expect(cssResponse.body).toContain( + "/api/font-packs/official-cjk/1.0.0/serif-regular/web?v=" + ); + + const assetResponse = await app.inject({ + method: "GET", + url: `/api/font-packs/official-cjk/1.0.0/serif-regular/web?v=${sha256(fontPack.web)}` + }); + expect(assetResponse.statusCode).toBe(200); + expect(assetResponse.headers["content-type"]).toContain("font/woff2"); + expect(assetResponse.headers["cache-control"]).toContain("immutable"); + expect(assetResponse.rawPayload).toEqual(fontPack.web); + + const missingResponse = await app.inject({ + method: "GET", + url: "/api/font-packs/official-cjk/2.0.0/serif-regular/web" + }); + expect(missingResponse.statusCode).toBe(404); + }); + it("发现本地主题并安全提供相对字体资源", async () => { temporaryDirectory = await mkdtemp(join(tmpdir(), "md-to-pdf-theme-")); const themeDirectory = join(temporaryDirectory, "local-test"); diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 7289553..cc1b40e 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -287,6 +287,24 @@ DOCX 静态 TrueType 资源。注册器按根目录顺序和最高兼容版本 结果,并把字体包指纹纳入缓存和导出诊断。全仓 454 项测试、类型检查和 生产构建通过。 +阶段 12D-FP2 已将可选字体包接入统一主题与 DOCX 生产链。应用层按主题 +声明、字体家族或别名、字重和样式解析同一逻辑字体面:Web、Preview 与 +PDF 在主题 CSS 末尾注入 WOFF2 `@font-face`,DOCX 同时选用对应的静态 +TrueType 字体,并保留字体包许可、版本和来源诊断;未命中的字体面继续 +安全降级到主题原有字体。字体包版本、资源 SHA-256 和整体指纹已进入 +主题令牌、样式快照、动态模板及导出缓存边界,替换资源不会错误复用旧 +结果。Server 只暴露带 ETag 和不可变缓存头的 WOFF2 资源端点,通过 +`FONT_PACK_ROOT` 配置字体包根目录;Desktop 使用受限的 +`mdpdf://font-pack/.../web` 协议,并从应用数据目录发现可选字体包,均不 +对渲染器暴露 DOCX TTF 原始资源。静态字体单面上限调整为 16 MiB、单次 +DOCX 字体源总量调整为 48 MiB,与已验证的原生中文 TrueType 资产一致。 + +全仓 458 项测试、类型检查、生产构建和 14 套主题真实 Pandoc 矩阵通过; +临时字体包验证确认政企红头标准文件可从同一字体包解析两个 WOFF2/TTF +字形面,其余字体继续按主题资产降级,最终五个字体源均通过 SFNT 与嵌入 +权限校验。字体二进制、独立安装包和 Docker 分发尚未进入仓库;下一步 +FP3 只处理独立 NSIS 字体包、Docker 可选挂载及其安装/升级/卸载门禁。 + ## 2. 已完成 ### 2.1 项目骨架 @@ -1172,8 +1190,9 @@ ECharts 第二阶段浏览器与 PDF 验证结果: 栅格差异、可配置视觉门限以及 JSON/HTML 验收报告;该包仅用于开发和 发布验收,不进入应用运行时。 - `packages/font-pack-registry` 负责可选字体包协议、版本兼容、安全发现、 - 资源哈希与容量门禁、确定性版本选择和逻辑字体面匹配;FP1 尚未将它 - 接入应用服务或发行包。 + 资源哈希与容量门禁、确定性版本选择和逻辑字体面匹配;应用层已将同一 + 解析结果用于主题 CSS 的 WOFF2 和 DOCX 的静态 TrueType,发行资产与 + 安装逻辑留在 FP3。 - `packages/application/src/docx-export-service.ts` 负责 DOCX capability、 并发排队、总超时、取消、跨端转换编排、错误码和耗时诊断。 - `apps/desktop/src/desktop-application-controller.ts` 负责 Electron @@ -1235,9 +1254,11 @@ ECharts 第二阶段浏览器与 PDF 验证结果: 字重规范化及通用 SFNT 到 Word 字体元数据映射,并通过 Word/WPS 原生视觉与可编辑性验收; - 阶段 12D-FP1:已完成可选字体包协议、安全注册器、版本/根目录优先级、 - 资源双重校验和字体候选匹配;下一步 FP2 接入 Preview/PDF 与 DOCX - 同源字体解析,FP3 再实现独立 NSIS 字体包与 Docker 可选挂载,最终由 - R4 完成 14 套主题深度视觉回归; + 资源双重校验和字体候选匹配; +- 阶段 12D-FP2:已完成 Preview/PDF WOFF2 与 DOCX 静态 TrueType 的 + 同源字体解析、跨端受限资源入口、缓存指纹、降级诊断和真实字体包验证; + 下一步 FP3 实现独立 NSIS 字体包与 Docker 可选挂载,最终由 R4 完成 + 14 套主题深度视觉回归; - 阶段 13:完成 Word/WPS 双向互存、外部主题兼容、体积和正式发布验收。 每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入 diff --git a/package-lock.json b/package-lock.json index a295fd4..cdeafb0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9969,6 +9969,7 @@ "@md-to-pdf/core": "0.1.0", "@md-to-pdf/docx-engine": "0.1.0", "@md-to-pdf/docx-theme-engine": "0.1.0", + "@md-to-pdf/font-pack-registry": "0.1.0", "@md-to-pdf/renderer": "0.1.0" }, "devDependencies": { diff --git a/package.json b/package.json index b1ad73e..3ca1862 100644 --- a/package.json +++ b/package.json @@ -10,12 +10,13 @@ ], "scripts": { "build": "npm run build:web-runtime && npm run build -w @md-to-pdf/server && npm run build -w @md-to-pdf/desktop", - "build:web-runtime": "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/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 && npm run build -w @md-to-pdf/web", - "dev": "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/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 && concurrently -k -n markdown-echarts,core,semantic-document,docx-theme-engine,docx-engine,renderer,application,preview-engine,server,web \"npm:dev:markdown-echarts\" \"npm:dev:core\" \"npm:dev:semantic-document\" \"npm:dev:docx-theme-engine\" \"npm:dev:docx-engine\" \"npm:dev:renderer\" \"npm:dev:application\" \"npm:dev:preview-engine\" \"npm:dev:server\" \"npm:dev:web\"", + "build:web-runtime": "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 && npm run build -w @md-to-pdf/web", + "dev": "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 && concurrently -k -n markdown-echarts,core,semantic-document,docx-theme-engine,font-pack-registry,docx-engine,renderer,application,preview-engine,server,web \"npm:dev:markdown-echarts\" \"npm:dev:core\" \"npm:dev:semantic-document\" \"npm:dev:docx-theme-engine\" \"npm:dev:font-pack-registry\" \"npm:dev:docx-engine\" \"npm:dev:renderer\" \"npm:dev:application\" \"npm:dev:preview-engine\" \"npm:dev:server\" \"npm:dev:web\"", "dev:markdown-echarts": "npm run dev -w @md-to-pdf/markdown-echarts", "dev:core": "npm run dev -w @md-to-pdf/core", "dev:semantic-document": "npm run dev -w @md-to-pdf/semantic-document", "dev:docx-theme-engine": "npm run dev -w @md-to-pdf/docx-theme-engine", + "dev:font-pack-registry": "tsc -p packages/font-pack-registry/tsconfig.json --watch --preserveWatchOutput", "dev:docx-engine": "npm run dev -w @md-to-pdf/docx-engine", "dev:renderer": "npm run dev -w @md-to-pdf/renderer", "dev:application": "npm run dev -w @md-to-pdf/application", @@ -23,17 +24,17 @@ "dev:server": "npm run dev -w @md-to-pdf/server", "dev:web": "npm run dev -w @md-to-pdf/web", "dev:desktop": "npm run dev -w @md-to-pdf/desktop", - "desktop:dev": "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-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 && concurrently -k -n web,desktop \"npm:dev:web\" \"npm:dev:desktop\"", + "desktop:dev": "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/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 && concurrently -k -n web,desktop \"npm:dev:web\" \"npm:dev:desktop\"", "desktop:package": "npm run package -w @md-to-pdf/desktop", "theme:import-typora": "node scripts/import-typora-theme.mjs", "verify:docx-reference": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-engine && npm run verify:pandoc -w @md-to-pdf/docx-engine", "verify:docx-conversion": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-engine && npm run verify:conversion -w @md-to-pdf/docx-engine", "verify:docx-matrix": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-engine && npm run verify:acceptance -w @md-to-pdf/docx-engine", "verify:docx-themes": "npm run build:web-runtime && npm run build -w @md-to-pdf/server && npm run verify:docx-theme-styles -w @md-to-pdf/server && npm run verify:themes -w @md-to-pdf/docx-engine", - "verify:docx-theme-styles": "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/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/server && npm run verify:docx-theme-styles -w @md-to-pdf/server && npm run verify:docx-theme-styles -w @md-to-pdf/desktop", + "verify:docx-theme-styles": "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/server && npm run verify:docx-theme-styles -w @md-to-pdf/server && npm run verify:docx-theme-styles -w @md-to-pdf/desktop", "verify:docx-acceptance": "npm run build:web-runtime && npm run build -w @md-to-pdf/server && npm run build -w @md-to-pdf/desktop && npm run verify:pandoc -w @md-to-pdf/docx-engine && npm run verify:conversion -w @md-to-pdf/docx-engine && npm run verify:acceptance -w @md-to-pdf/docx-engine && npm run verify:docx-http -w @md-to-pdf/server && npm run test -w @md-to-pdf/desktop -- desktop-docx-save.test.ts", "verify:docx-http": "npm run build:web-runtime && npm run build -w @md-to-pdf/server && npm run verify:docx-http -w @md-to-pdf/server", - "test": "npm run test -w @md-to-pdf/markdown-echarts && npm run test -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run test -w @md-to-pdf/semantic-document && npm run build -w @md-to-pdf/semantic-document && npm run test -w @md-to-pdf/docx-theme-engine && npm run test -w @md-to-pdf/document-visual-diff && npm run test -w @md-to-pdf/font-pack-registry && npm run test -w @md-to-pdf/docx-engine && npm run test -w @md-to-pdf/renderer && npm run test -w @md-to-pdf/application && npm run build -w @md-to-pdf/application && npm run test -w @md-to-pdf/preview-engine && npm run build -w @md-to-pdf/preview-engine && npm run test -w @md-to-pdf/web && npm run test -w @md-to-pdf/server && npm run test -w @md-to-pdf/desktop", + "test": "npm run test -w @md-to-pdf/markdown-echarts && npm run test -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run test -w @md-to-pdf/semantic-document && npm run build -w @md-to-pdf/semantic-document && npm run test -w @md-to-pdf/docx-theme-engine && npm run test -w @md-to-pdf/document-visual-diff && npm run test -w @md-to-pdf/font-pack-registry && npm run build -w @md-to-pdf/font-pack-registry && npm run test -w @md-to-pdf/docx-engine && npm run test -w @md-to-pdf/renderer && npm run test -w @md-to-pdf/application && npm run build -w @md-to-pdf/application && npm run test -w @md-to-pdf/preview-engine && npm run build -w @md-to-pdf/preview-engine && npm run test -w @md-to-pdf/web && npm run test -w @md-to-pdf/server && npm run test -w @md-to-pdf/desktop", "typecheck": "npm run typecheck -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/markdown-echarts && npm run typecheck -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run typecheck -w @md-to-pdf/semantic-document && npm run build -w @md-to-pdf/semantic-document && npm run typecheck -w @md-to-pdf/docx-theme-engine && npm run build -w @md-to-pdf/docx-theme-engine && npm run typecheck -w @md-to-pdf/document-visual-diff && npm run typecheck -w @md-to-pdf/font-pack-registry && npm run typecheck -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/docx-engine && npm run typecheck -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/renderer && npm run typecheck -w @md-to-pdf/application && npm run build -w @md-to-pdf/application && npm run typecheck -w @md-to-pdf/preview-engine && npm run build -w @md-to-pdf/preview-engine && npm run typecheck -w @md-to-pdf/web && npm run typecheck -w @md-to-pdf/server && npm run typecheck -w @md-to-pdf/desktop" }, "engines": { diff --git a/packages/application/package.json b/packages/application/package.json index 3a94296..a6b9c40 100644 --- a/packages/application/package.json +++ b/packages/application/package.json @@ -24,6 +24,7 @@ "@md-to-pdf/core": "0.1.0", "@md-to-pdf/docx-engine": "0.1.0", "@md-to-pdf/docx-theme-engine": "0.1.0", + "@md-to-pdf/font-pack-registry": "0.1.0", "@md-to-pdf/renderer": "0.1.0" }, "devDependencies": { diff --git a/packages/application/src/application-service.ts b/packages/application/src/application-service.ts index 75c6363..bfe5282 100644 --- a/packages/application/src/application-service.ts +++ b/packages/application/src/application-service.ts @@ -11,6 +11,7 @@ import { import type { DocxFontSource } from "@md-to-pdf/docx-engine"; import { createThemeRegistry, + type ThemeFontPackStatus, type ThemeRegistryOptions } from "./theme-registry.js"; import { @@ -50,6 +51,7 @@ export interface PreparedDocxExport { source: "bundled" | "local"; css: string; fonts: DocxFontSource[]; + fontPack?: ThemeFontPackStatus; }; } @@ -188,10 +190,11 @@ export function createApplicationService( ); } - const [document, themeCss, themeFonts] = await Promise.all([ + const [document, themeCss, themeFonts, fontPack] = await Promise.all([ render(parsed.data, context), themes.getCss(theme.manifest.id), - themes.getDocxFonts(theme.manifest.id) + themes.getDocxFonts(theme.manifest.id), + themes.getFontPackStatus(theme.manifest.id) ]); if (themeCss === undefined || themeFonts === undefined) { throw new ApplicationRequestError( @@ -208,7 +211,8 @@ export function createApplicationService( manifest: theme.manifest, source: theme.source, css: themeCss, - fonts: themeFonts + fonts: themeFonts, + ...(fontPack ? { fontPack } : {}) } }; } @@ -216,6 +220,7 @@ export function createApplicationService( return { getThemeAsset: themes.getAsset, getThemeCss: themes.getCss, + getFontPackAsset: themes.getFontPackAsset, invalidateThemes: themes.invalidate, listThemes, prepareDocxExport, diff --git a/packages/application/src/docx-export-service.ts b/packages/application/src/docx-export-service.ts index 3afee90..90f315b 100644 --- a/packages/application/src/docx-export-service.ts +++ b/packages/application/src/docx-export-service.ts @@ -407,7 +407,10 @@ export class DocxExportService { diagnostics: { warnings: [ ...media.warnings, - ...themeTokens.warnings + ...themeTokens.warnings, + ...(prepared.theme.fontPack?.diagnostics ?? []) + .filter((diagnostic) => diagnostic.severity !== "info") + .map((diagnostic) => diagnostic.message) ], echartsErrors: media.echartsErrors, mermaidErrors: media.mermaidErrors diff --git a/packages/application/src/index.ts b/packages/application/src/index.ts index 878a36b..ed2cd92 100644 --- a/packages/application/src/index.ts +++ b/packages/application/src/index.ts @@ -47,6 +47,7 @@ export { } from "./docx-export-service.js"; export { createThemeRegistry, + type ThemeFontPackStatus, type ThemeRecord, type ThemeRegistryOptions } from "./theme-registry.js"; diff --git a/packages/application/src/theme-registry.ts b/packages/application/src/theme-registry.ts index b406240..7baa94f 100644 --- a/packages/application/src/theme-registry.ts +++ b/packages/application/src/theme-registry.ts @@ -18,6 +18,15 @@ import { type DocxFontFace, type ThemeManifest } from "@md-to-pdf/core"; +import { + discoverFontPacks, + readInstalledFontPackAsset, + resolveFontPackFaces, + type FontPackDiagnostic, + type FontPackFaceRequest, + type FontPackRegistryResult, + type ResolvedFontPackFace +} from "@md-to-pdf/font-pack-registry"; export interface ThemeRecord { manifest: ThemeManifest; @@ -35,6 +44,34 @@ export interface ThemeRegistryOptions { onWarning?: (message: string) => void; cacheTtlMs?: number; createAssetUrl?: (themeId: string, assetPath: string) => string; + fontPacks?: { + roots: readonly string[]; + appVersion: string; + cacheTtlMs?: number; + preferredPackIds?: readonly string[]; + createAssetUrl?: ( + packId: string, + packVersion: string, + faceId: string, + kind: "web" | "docx", + sha256: string + ) => string; + }; +} + +export interface ThemeFontPackStatus { + registryFingerprint: string; + appliedFaces: Array<{ + family: string; + weight: number; + style: "normal" | "italic"; + packId: string; + packVersion: string; + faceId: string; + webSha256: string; + docxSha256: string; + }>; + diagnostics: FontPackDiagnostic[]; } const assetContentTypes: Record = { @@ -322,6 +359,148 @@ export function createThemeRegistry(options: ThemeRegistryOptions) { } | undefined; let sharedFontCssPromise: Promise | undefined; + let cachedFontPacks: + | { + expiresAt: number; + promise: Promise; + } + | undefined; + const reportedFontPackDiagnostics = new Set(); + const fontPackCacheTtlMs = + options.fontPacks?.cacheTtlMs ?? 5 * 60_000; + if (!Number.isFinite(fontPackCacheTtlMs) || fontPackCacheTtlMs < 0) { + throw new Error("字体包缓存有效期必须是非负有限数值"); + } + const createFontPackAssetUrl = + options.fontPacks?.createAssetUrl ?? + (( + packId: string, + packVersion: string, + faceId: string, + kind: "web" | "docx", + fingerprint: string + ) => + `/api/font-packs/${encodeURIComponent( + packId + )}/${encodeURIComponent(packVersion)}/${encodeURIComponent( + faceId + )}/${kind}?v=${fingerprint}`); + + function listFontPacks() { + if (!options.fontPacks) { + return Promise.resolve({ + packs: [], + diagnostics: [], + fingerprint: "0".repeat(64) + }); + } + const now = Date.now(); + if (cachedFontPacks && now < cachedFontPacks.expiresAt) { + return cachedFontPacks.promise; + } + const promise = discoverFontPacks({ + roots: options.fontPacks.roots, + appVersion: options.fontPacks.appVersion + }).then((result) => { + for (const item of result.diagnostics) { + if (item.severity === "info") { + continue; + } + const key = [ + item.code, + item.root ?? "", + item.packId ?? "", + item.packVersion ?? "", + item.message + ].join(":"); + if (!reportedFontPackDiagnostics.has(key)) { + reportedFontPackDiagnostics.add(key); + options.onWarning?.(`字体包 ${item.code}:${item.message}`); + } + } + return result; + }); + cachedFontPacks = { + expiresAt: now + fontPackCacheTtlMs, + promise + }; + void promise.catch(() => { + if (cachedFontPacks?.promise === promise) { + cachedFontPacks = undefined; + } + }); + return promise; + } + + async function resolveThemeFontPacks(theme: ThemeRecord) { + const registry = await listFontPacks(); + const faces = theme.manifest.docxFonts?.faces ?? []; + const requests: FontPackFaceRequest[] = faces.map((face) => ({ + family: face.family, + aliases: face.aliases, + weight: face.weight, + style: face.style + })); + const resolution = resolveFontPackFaces(requests, registry.packs, { + preferredPackIds: options.fontPacks?.preferredPackIds ?? [], + reportMissing: registry.packs.length > 0 + }); + const matches = new Map(); + for (const match of resolution.resolved) { + const index = requests.indexOf(match.request); + const face = index < 0 ? undefined : faces[index]; + if (face) { + matches.set(face, match); + } + } + return { registry, resolution, matches }; + } + + function createFontPackCss( + matches: ReadonlyMap + ) { + if (matches.size === 0) { + return ""; + } + const fingerprints = new Set(); + const rules: string[] = []; + const emitted = new Set(); + for (const [request, match] of matches) { + fingerprints.add( + `${match.pack.id}@${match.pack.version}:${match.pack.fingerprint}` + ); + for (const target of match.face.targets) { + const key = `${target.toLocaleLowerCase("en-US")}:${ + request.weight + }:${request.style}`; + if (emitted.has(key)) { + continue; + } + emitted.add(key); + rules.push( + [ + "@font-face {", + ` font-family: "${target}";`, + ` src: url("${createFontPackAssetUrl( + match.pack.id, + match.pack.version, + match.face.id, + "web", + match.face.web.sha256 + )}") format("woff2");`, + ` font-weight: ${request.weight};`, + ` font-style: ${request.style};`, + " font-display: block;", + "}" + ].join("\n") + ); + } + } + return [ + `/* md-to-pdf-font-packs:${[...fingerprints].sort().join(",")} */`, + ...rules + ].join("\n"); + } function loadSharedFontCss() { if (!sharedFontCssPromise) { @@ -408,6 +587,8 @@ export function createThemeRegistry(options: ThemeRegistryOptions) { function invalidate() { cachedThemes = undefined; sharedFontCssPromise = undefined; + cachedFontPacks = undefined; + reportedFontPackDiagnostics.clear(); } async function get(themeId: string) { @@ -427,15 +608,67 @@ export function createThemeRegistry(options: ThemeRegistryOptions) { ? [theme.manifest.print] : []) ]; - const css = await Promise.all( + const [css, fontPacks] = await Promise.all([ + Promise.all( cssFiles.map((path) => loadThemeCssFile(theme, path, createAssetUrl) ) - ); + ), + resolveThemeFontPacks(theme) + ]); if (theme.source === "bundled") { css.unshift(await loadSharedFontCss()); } - return css.filter(Boolean).join("\n"); + return [...css, createFontPackCss(fontPacks.matches)] + .filter(Boolean) + .join("\n"); + } + + async function getFontPackAsset( + packId: string, + packVersion: string, + faceId: string, + kind: "web" | "docx" + ) { + const registry = await listFontPacks(); + const pack = registry.packs.find( + (candidate) => + candidate.id === packId && candidate.version === packVersion + ); + const face = pack?.faces.find((candidate) => candidate.id === faceId); + if (!face) { + return undefined; + } + const asset = face[kind]; + return { + contentType: kind === "web" ? "font/woff2" : "font/ttf", + content: await readInstalledFontPackAsset(asset), + sha256: asset.sha256 + }; + } + + async function getFontPackStatus( + themeId: string + ): Promise { + const theme = await get(themeId); + if (!theme) { + return undefined; + } + const { registry, resolution } = await resolveThemeFontPacks(theme); + return { + registryFingerprint: registry.fingerprint, + appliedFaces: resolution.resolved.map((match) => ({ + family: match.request.family, + weight: match.request.weight, + style: match.request.style, + packId: match.pack.id, + packVersion: match.pack.version, + faceId: match.face.id, + webSha256: match.face.web.sha256, + docxSha256: match.face.docx.sha256 + })), + diagnostics: [...registry.diagnostics, ...resolution.diagnostics] + }; } async function getAsset(themeId: string, assetPath: string) { @@ -479,8 +712,21 @@ export function createThemeRegistry(options: ThemeRegistryOptions) { return undefined; } const faces = theme.manifest.docxFonts?.faces ?? []; + const fontPacks = await resolveThemeFontPacks(theme); const fonts = await Promise.all( faces.map(async (face) => { + const matched = fontPacks.matches.get(face); + if (matched) { + return { + ...face, + aliases: [ + ...new Set([...face.aliases, ...matched.face.targets]) + ], + source: `font-pack:${matched.pack.id}/${matched.pack.version}/${matched.face.id}/docx`, + license: matched.pack.license, + content: await readInstalledFontPackAsset(matched.face.docx) + }; + } const shared = face.source.startsWith( sharedThemeAssetScheme ); @@ -519,6 +765,8 @@ export function createThemeRegistry(options: ThemeRegistryOptions) { getAsset, getCss, getDocxFonts, + getFontPackAsset, + getFontPackStatus, invalidate, list }; diff --git a/packages/application/tests/application-service.test.ts b/packages/application/tests/application-service.test.ts index c5b55f2..1396f1c 100644 --- a/packages/application/tests/application-service.test.ts +++ b/packages/application/tests/application-service.test.ts @@ -13,6 +13,7 @@ import { } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { createHash } from "node:crypto"; import { ApplicationRequestError, createApplicationService @@ -126,6 +127,64 @@ async function createThemeFixture() { return { bundledRoot, localRoot }; } +function sha256(content: Uint8Array | string) { + return createHash("sha256").update(content).digest("hex"); +} + +async function createFontPackFixture( + options: { corruptWebHash?: boolean; minimumAppVersion?: string } = {} +) { + const root = join(temporaryDirectory!, "font-packs"); + const directory = join(root, "test-font-pack", "1.0.0"); + const web = Buffer.from([10, 11, 12, 13, 14]); + const docx = Buffer.from([20, 21, 22, 23, 24, 25]); + const license = "SIL Open Font License 1.1"; + await mkdir(join(directory, "fonts"), { recursive: true }); + await writeFile(join(directory, "fonts", "test.woff2"), web); + await writeFile(join(directory, "fonts", "test.ttf"), docx); + await writeFile(join(directory, "OFL.txt"), license, "utf8"); + await writeFile( + join(directory, "font-pack.json"), + JSON.stringify({ + manifestVersion: 1, + id: "test-font-pack", + version: "1.0.0", + name: "测试字体包", + description: "应用服务字体包测试", + license: "OFL-1.1", + licenseFile: "OFL.txt", + licenseSha256: sha256(license), + licenseBytes: Buffer.byteLength(license), + compatibility: { + minimumAppVersion: options.minimumAppVersion ?? "0.6.0", + maximumAppVersionExclusive: "0.7.0" + }, + faces: [ + { + id: "test-regular", + targets: ["Test"], + weight: 400, + style: "normal", + web: { + path: "fonts/test.woff2", + format: "woff2", + bytes: web.byteLength, + sha256: options.corruptWebHash ? "0".repeat(64) : sha256(web) + }, + docx: { + path: "fonts/test.ttf", + format: "truetype", + bytes: docx.byteLength, + sha256: sha256(docx) + } + } + ] + }), + "utf8" + ); + return { root, web, docx }; +} + describe("共享应用服务", () => { it("渲染安全 Markdown 文档", async () => { const roots = await createThemeFixture(); @@ -350,6 +409,98 @@ describe("共享应用服务", () => { ]); }); + it("为主题 CSS 与 DOCX 选择同一字体包的双资源", async () => { + const roots = await createThemeFixture(); + const fontPack = await createFontPackFixture(); + const service = createApplicationService({ + ...roots, + fontPacks: { + roots: [fontPack.root], + appVersion: "0.6.0", + createAssetUrl: (packId, version, faceId, kind, hash) => + `mdpdf://font-pack/${packId}/${version}/${faceId}/${kind}?v=${hash}` + } + }); + + const css = await service.getThemeCss("test-theme"); + expect(css).toContain("md-to-pdf-font-packs:test-font-pack@1.0.0"); + expect(css).toContain( + "mdpdf://font-pack/test-font-pack/1.0.0/test-regular/web?v=" + ); + const prepared = await service.prepareDocxExport({ + markdown: "# 字体包", + fileName: "字体包.md", + exportConfig: { + ...defaultExportConfig, + themeId: "test-theme" + } + }); + expect(prepared.theme.fonts[0]).toEqual( + expect.objectContaining({ + family: "Test", + source: "font-pack:test-font-pack/1.0.0/test-regular/docx", + license: "OFL-1.1", + content: new Uint8Array(fontPack.docx) + }) + ); + expect(prepared.theme.fontPack?.appliedFaces).toEqual([ + expect.objectContaining({ + family: "Test", + packId: "test-font-pack", + faceId: "test-regular" + }) + ]); + await expect( + service.getFontPackAsset( + "test-font-pack", + "1.0.0", + "test-regular", + "web" + ) + ).resolves.toEqual({ + contentType: "font/woff2", + content: new Uint8Array(fontPack.web), + sha256: sha256(fontPack.web) + }); + }); + + it("字体包损坏时记录诊断并回退到主题字体", async () => { + const roots = await createThemeFixture(); + const fontPack = await createFontPackFixture({ corruptWebHash: true }); + const onWarning = vi.fn(); + const service = createApplicationService({ + ...roots, + onWarning, + fontPacks: { + roots: [fontPack.root], + appVersion: "0.6.0" + } + }); + + const prepared = await service.prepareDocxExport({ + markdown: "# 回退", + fileName: "回退.md", + exportConfig: { + ...defaultExportConfig, + themeId: "test-theme" + } + }); + expect(prepared.theme.css).not.toContain("md-to-pdf-font-packs:"); + expect(prepared.theme.fonts[0]).toEqual( + expect.objectContaining({ + family: "Test", + source: "fonts/test.woff2", + content: Buffer.from([0, 1, 2, 3]) + }) + ); + expect(prepared.theme.fontPack?.diagnostics[0]?.code).toBe( + "MANIFEST_INVALID" + ); + expect(onWarning).toHaveBeenCalledWith( + expect.stringContaining("字体包 MANIFEST_INVALID") + ); + }); + it("以稳定错误码拒绝非法 DOCX 配置和缺失主题", async () => { const roots = await createThemeFixture(); const service = createApplicationService(roots); diff --git a/packages/core/src/theme.ts b/packages/core/src/theme.ts index db12bdb..22c7c53 100644 --- a/packages/core/src/theme.ts +++ b/packages/core/src/theme.ts @@ -9,9 +9,9 @@ import { export const THEME_MANIFEST_VERSION = 1; export const MAXIMUM_DOCX_FONT_FACE_COUNT = 16; -export const MAXIMUM_DOCX_FONT_SOURCE_BYTES = 8 * 1024 * 1024; +export const MAXIMUM_DOCX_FONT_SOURCE_BYTES = 16 * 1024 * 1024; export const MAXIMUM_DOCX_FONT_TOTAL_SOURCE_BYTES = - 32 * 1024 * 1024; + 48 * 1024 * 1024; const docxFontNameSchema = z .string()