feat: 接入可选字体包同源渲染链

This commit is contained in:
SkyJourney
2026-07-31 21:15:45 +08:00
parent c76454be9d
commit 9f96d51922
17 changed files with 720 additions and 24 deletions
+30
View File
@@ -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;
}
+64 -1
View File
@@ -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 });
}
+4 -1
View File
@@ -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;
+25
View File
@@ -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("字体包资源地址无效");
});
});
+43 -2
View File
@@ -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<ApplicationServiceOptions["fontPacks"]>;
}
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) => {
+10 -1
View File
@@ -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() {
+93
View File
@@ -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");
+26 -5
View File
@@ -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 双向互存、外部主题兼容、体积和正式发布验收。
每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入
+1
View File
@@ -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": {
+6 -5
View File
@@ -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": {
+1
View File
@@ -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": {
@@ -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,
@@ -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
+1
View File
@@ -47,6 +47,7 @@ export {
} from "./docx-export-service.js";
export {
createThemeRegistry,
type ThemeFontPackStatus,
type ThemeRecord,
type ThemeRegistryOptions
} from "./theme-registry.js";
+251 -3
View File
@@ -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<string, string> = {
@@ -322,6 +359,148 @@ export function createThemeRegistry(options: ThemeRegistryOptions) {
}
| undefined;
let sharedFontCssPromise: Promise<string> | undefined;
let cachedFontPacks:
| {
expiresAt: number;
promise: Promise<FontPackRegistryResult>;
}
| undefined;
const reportedFontPackDiagnostics = new Set<string>();
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<FontPackRegistryResult>({
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<DocxFontFace, ResolvedFontPackFace>();
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<DocxFontFace, ResolvedFontPackFace>
) {
if (matches.size === 0) {
return "";
}
const fingerprints = new Set<string>();
const rules: string[] = [];
const emitted = new Set<string>();
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<ThemeFontPackStatus | undefined> {
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
};
@@ -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);
+2 -2
View File
@@ -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()