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");