feat: 接入可选字体包同源渲染链
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user