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("/") 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, createApplicationService,
type ApplicationService type ApplicationService
} from "@md-to-pdf/application"; } from "@md-to-pdf/application";
import { parseThemeResourceUrl } from "./application-contract.js"; import {
parseFontPackResourceUrl,
parseThemeResourceUrl
} from "./application-contract.js";
import { import {
DesktopApplicationController DesktopApplicationController
} from "./desktop-application-controller.js"; } from "./desktop-application-controller.js";
@@ -26,6 +29,7 @@ import {
const APP_SCHEME = "mdpdf"; const APP_SCHEME = "mdpdf";
const APP_HOST = "bundle"; const APP_HOST = "bundle";
const THEME_HOST = "theme"; const THEME_HOST = "theme";
const FONT_PACK_HOST = "font-pack";
const currentDirectory = path.dirname(fileURLToPath(import.meta.url)); const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
let applicationController: DesktopApplicationController | undefined; let applicationController: DesktopApplicationController | undefined;
let pendingExternalMarkdownPath = findMarkdownFileArgument( let pendingExternalMarkdownPath = findMarkdownFileArgument(
@@ -102,6 +106,21 @@ function getDesktopThemeDirectory() {
: path.resolve(currentDirectory, "../../..", ".local", "themes"); : 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() { function createDesktopApplicationService() {
const projectRoot = path.resolve(currentDirectory, "../../.."); const projectRoot = path.resolve(currentDirectory, "../../..");
return createApplicationService({ return createApplicationService({
@@ -111,6 +130,22 @@ function createDesktopApplicationService() {
localRoot: getDesktopThemeDirectory(), localRoot: getDesktopThemeDirectory(),
createAssetUrl: (themeId, assetPath) => createAssetUrl: (themeId, assetPath) =>
`${APP_SCHEME}://${THEME_HOST}/${encodeURIComponent(themeId)}/${encodeThemeAssetPath(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) onWarning: (message) => console.warn(message)
}); });
} }
@@ -155,6 +190,34 @@ async function registerApplicationProtocol(
return new Response("Not found", { status: 404 }); 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) { if (url.host !== APP_HOST) {
return new Response("Not found", { status: 404 }); 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)) { if (["about:", "blob:", "data:"].includes(requested.protocol)) {
return true; return true;
} }
if (requested.protocol === "mdpdf:" && requested.host === "theme") { if (
requested.protocol === "mdpdf:" &&
["theme", "font-pack"].includes(requested.host)
) {
return true; return true;
} }
return requested.origin === new URL(renderUrl).origin; return requested.origin === new URL(renderUrl).origin;
+25
View File
@@ -10,6 +10,7 @@ import {
} from "../src/pdf-contract.js"; } from "../src/pdf-contract.js";
import { import {
parseMarkdownRenderRequest, parseMarkdownRenderRequest,
parseFontPackResourceUrl,
parseThemeId, parseThemeId,
parseThemeResourceUrl parseThemeResourceUrl
} from "../src/application-contract.js"; } from "../src/application-contract.js";
@@ -105,6 +106,12 @@ describe("桌面 PDF 网络边界", () => {
renderUrl renderUrl
) )
).toBe(true); ).toBe(true);
expect(
isAllowedPdfRuntimeUrl(
"mdpdf://font-pack/official-cjk/1.0.0/serif/web?v=abc",
renderUrl
)
).toBe(true);
}); });
it("拒绝外部网络资源", () => { it("拒绝外部网络资源", () => {
@@ -143,4 +150,22 @@ describe("桌面应用服务 IPC 载荷", () => {
parseThemeResourceUrl("mdpdf://bundle/index.html") parseThemeResourceUrl("mdpdf://bundle/index.html")
).toBeUndefined(); ).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, DocxThemeTokenService,
createApplicationService, createApplicationService,
readDocxExportRuntimeLimits, readDocxExportRuntimeLimits,
type ApplicationService type ApplicationService,
type ApplicationServiceOptions
} from "@md-to-pdf/application"; } from "@md-to-pdf/application";
import { import {
PandocDocxConverter, PandocDocxConverter,
@@ -88,6 +89,7 @@ export interface BuildAppOptions {
"getCapability" | "generate" | "close" "getCapability" | "generate" | "close"
>; >;
docxMediaAdapter?: ServerDocxMediaCaptureAdapter; docxMediaAdapter?: ServerDocxMediaCaptureAdapter;
fontPacks?: NonNullable<ApplicationServiceOptions["fontPacks"]>;
} }
function milliseconds(value: number) { function milliseconds(value: number) {
@@ -161,7 +163,8 @@ export function buildApp(options: BuildAppOptions = {}) {
bundledRoot: resolve(projectRoot, "themes"), bundledRoot: resolve(projectRoot, "themes"),
localRoot: localRoot:
options.localThemeRoot ?? resolve(projectRoot, ".local", "themes"), 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 = const pdfGenerator =
options.pdfGenerator ?? createPdfGenerator(); 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 }>( app.post<{ Body: RenderRequestBody }>(
"/api/render", "/api/render",
async (request, reply) => { 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 port = Number.parseInt(process.env.PORT ?? "3001", 10);
const host = process.env.HOST ?? "0.0.0.0"; const host = process.env.HOST ?? "0.0.0.0";
const fontPackRoot = process.env.FONT_PACK_ROOT?.trim();
const app = buildApp({ const app = buildApp({
prewarmPdfBrowser: true, prewarmPdfBrowser: true,
prewarmDocxRuntime: true prewarmDocxRuntime: true,
...(fontPackRoot
? {
fontPacks: {
roots: [fontPackRoot],
appVersion: process.env.APP_VERSION ?? "0.6.0"
}
}
: {})
}); });
async function start() { 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 { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { createHash } from "node:crypto";
import { buildApp } from "../src/app.js"; import { buildApp } from "../src/app.js";
import { import {
createPdfContentDisposition, 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 () => { afterEach(async () => {
await app?.close(); await app?.close();
if (temporaryDirectory) { if (temporaryDirectory) {
@@ -136,6 +192,43 @@ describe("预览 API", () => {
expect(response.body).toContain("#write"); 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 () => { it("发现本地主题并安全提供相对字体资源", async () => {
temporaryDirectory = await mkdtemp(join(tmpdir(), "md-to-pdf-theme-")); temporaryDirectory = await mkdtemp(join(tmpdir(), "md-to-pdf-theme-"));
const themeDirectory = join(temporaryDirectory, "local-test"); const themeDirectory = join(temporaryDirectory, "local-test");
+26 -5
View File
@@ -287,6 +287,24 @@ DOCX 静态 TrueType 资源。注册器按根目录顺序和最高兼容版本
结果,并把字体包指纹纳入缓存和导出诊断。全仓 454 项测试、类型检查和 结果,并把字体包指纹纳入缓存和导出诊断。全仓 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. 已完成
### 2.1 项目骨架 ### 2.1 项目骨架
@@ -1172,8 +1190,9 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
栅格差异、可配置视觉门限以及 JSON/HTML 验收报告;该包仅用于开发和 栅格差异、可配置视觉门限以及 JSON/HTML 验收报告;该包仅用于开发和
发布验收,不进入应用运行时。 发布验收,不进入应用运行时。
- `packages/font-pack-registry` 负责可选字体包协议、版本兼容、安全发现、 - `packages/font-pack-registry` 负责可选字体包协议、版本兼容、安全发现、
资源哈希与容量门禁、确定性版本选择和逻辑字体面匹配;FP1 尚未将它 资源哈希与容量门禁、确定性版本选择和逻辑字体面匹配;应用层已将同一
接入应用服务或发行包。 解析结果用于主题 CSS 的 WOFF2 和 DOCX 的静态 TrueType,发行资产与
安装逻辑留在 FP3。
- `packages/application/src/docx-export-service.ts` 负责 DOCX capability、 - `packages/application/src/docx-export-service.ts` 负责 DOCX capability、
并发排队、总超时、取消、跨端转换编排、错误码和耗时诊断。 并发排队、总超时、取消、跨端转换编排、错误码和耗时诊断。
- `apps/desktop/src/desktop-application-controller.ts` 负责 Electron - `apps/desktop/src/desktop-application-controller.ts` 负责 Electron
@@ -1235,9 +1254,11 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
字重规范化及通用 SFNT 到 Word 字体元数据映射,并通过 Word/WPS 字重规范化及通用 SFNT 到 Word 字体元数据映射,并通过 Word/WPS
原生视觉与可编辑性验收; 原生视觉与可编辑性验收;
- 阶段 12D-FP1:已完成可选字体包协议、安全注册器、版本/根目录优先级、 - 阶段 12D-FP1:已完成可选字体包协议、安全注册器、版本/根目录优先级、
资源双重校验和字体候选匹配;下一步 FP2 接入 Preview/PDF 与 DOCX 资源双重校验和字体候选匹配;
同源字体解析,FP3 再实现独立 NSIS 字体包与 Docker 可选挂载,最终由 - 阶段 12D-FP2:已完成 Preview/PDF WOFF2 与 DOCX 静态 TrueType 的
R4 完成 14 套主题深度视觉回归 同源字体解析、跨端受限资源入口、缓存指纹、降级诊断和真实字体包验证
下一步 FP3 实现独立 NSIS 字体包与 Docker 可选挂载,最终由 R4 完成
14 套主题深度视觉回归;
- 阶段 13:完成 Word/WPS 双向互存、外部主题兼容、体积和正式发布验收。 - 阶段 13:完成 Word/WPS 双向互存、外部主题兼容、体积和正式发布验收。
每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入 每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入
+1
View File
@@ -9969,6 +9969,7 @@
"@md-to-pdf/core": "0.1.0", "@md-to-pdf/core": "0.1.0",
"@md-to-pdf/docx-engine": "0.1.0", "@md-to-pdf/docx-engine": "0.1.0",
"@md-to-pdf/docx-theme-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" "@md-to-pdf/renderer": "0.1.0"
}, },
"devDependencies": { "devDependencies": {
+6 -5
View File
@@ -10,12 +10,13 @@
], ],
"scripts": { "scripts": {
"build": "npm run build:web-runtime && npm run build -w @md-to-pdf/server && npm run build -w @md-to-pdf/desktop", "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", "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/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\"", "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:markdown-echarts": "npm run dev -w @md-to-pdf/markdown-echarts",
"dev:core": "npm run dev -w @md-to-pdf/core", "dev:core": "npm run dev -w @md-to-pdf/core",
"dev:semantic-document": "npm run dev -w @md-to-pdf/semantic-document", "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: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:docx-engine": "npm run dev -w @md-to-pdf/docx-engine",
"dev:renderer": "npm run dev -w @md-to-pdf/renderer", "dev:renderer": "npm run dev -w @md-to-pdf/renderer",
"dev:application": "npm run dev -w @md-to-pdf/application", "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:server": "npm run dev -w @md-to-pdf/server",
"dev:web": "npm run dev -w @md-to-pdf/web", "dev:web": "npm run dev -w @md-to-pdf/web",
"dev:desktop": "npm run dev -w @md-to-pdf/desktop", "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", "desktop:package": "npm run package -w @md-to-pdf/desktop",
"theme:import-typora": "node scripts/import-typora-theme.mjs", "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-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-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-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-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-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", "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" "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": { "engines": {
+1
View File
@@ -24,6 +24,7 @@
"@md-to-pdf/core": "0.1.0", "@md-to-pdf/core": "0.1.0",
"@md-to-pdf/docx-engine": "0.1.0", "@md-to-pdf/docx-engine": "0.1.0",
"@md-to-pdf/docx-theme-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" "@md-to-pdf/renderer": "0.1.0"
}, },
"devDependencies": { "devDependencies": {
@@ -11,6 +11,7 @@ import {
import type { DocxFontSource } from "@md-to-pdf/docx-engine"; import type { DocxFontSource } from "@md-to-pdf/docx-engine";
import { import {
createThemeRegistry, createThemeRegistry,
type ThemeFontPackStatus,
type ThemeRegistryOptions type ThemeRegistryOptions
} from "./theme-registry.js"; } from "./theme-registry.js";
import { import {
@@ -50,6 +51,7 @@ export interface PreparedDocxExport {
source: "bundled" | "local"; source: "bundled" | "local";
css: string; css: string;
fonts: DocxFontSource[]; 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), render(parsed.data, context),
themes.getCss(theme.manifest.id), 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) { if (themeCss === undefined || themeFonts === undefined) {
throw new ApplicationRequestError( throw new ApplicationRequestError(
@@ -208,7 +211,8 @@ export function createApplicationService(
manifest: theme.manifest, manifest: theme.manifest,
source: theme.source, source: theme.source,
css: themeCss, css: themeCss,
fonts: themeFonts fonts: themeFonts,
...(fontPack ? { fontPack } : {})
} }
}; };
} }
@@ -216,6 +220,7 @@ export function createApplicationService(
return { return {
getThemeAsset: themes.getAsset, getThemeAsset: themes.getAsset,
getThemeCss: themes.getCss, getThemeCss: themes.getCss,
getFontPackAsset: themes.getFontPackAsset,
invalidateThemes: themes.invalidate, invalidateThemes: themes.invalidate,
listThemes, listThemes,
prepareDocxExport, prepareDocxExport,
@@ -407,7 +407,10 @@ export class DocxExportService {
diagnostics: { diagnostics: {
warnings: [ warnings: [
...media.warnings, ...media.warnings,
...themeTokens.warnings ...themeTokens.warnings,
...(prepared.theme.fontPack?.diagnostics ?? [])
.filter((diagnostic) => diagnostic.severity !== "info")
.map((diagnostic) => diagnostic.message)
], ],
echartsErrors: media.echartsErrors, echartsErrors: media.echartsErrors,
mermaidErrors: media.mermaidErrors mermaidErrors: media.mermaidErrors
+1
View File
@@ -47,6 +47,7 @@ export {
} from "./docx-export-service.js"; } from "./docx-export-service.js";
export { export {
createThemeRegistry, createThemeRegistry,
type ThemeFontPackStatus,
type ThemeRecord, type ThemeRecord,
type ThemeRegistryOptions type ThemeRegistryOptions
} from "./theme-registry.js"; } from "./theme-registry.js";
+251 -3
View File
@@ -18,6 +18,15 @@ import {
type DocxFontFace, type DocxFontFace,
type ThemeManifest type ThemeManifest
} from "@md-to-pdf/core"; } 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 { export interface ThemeRecord {
manifest: ThemeManifest; manifest: ThemeManifest;
@@ -35,6 +44,34 @@ export interface ThemeRegistryOptions {
onWarning?: (message: string) => void; onWarning?: (message: string) => void;
cacheTtlMs?: number; cacheTtlMs?: number;
createAssetUrl?: (themeId: string, assetPath: string) => string; 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> = { const assetContentTypes: Record<string, string> = {
@@ -322,6 +359,148 @@ export function createThemeRegistry(options: ThemeRegistryOptions) {
} }
| undefined; | undefined;
let sharedFontCssPromise: Promise<string> | 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() { function loadSharedFontCss() {
if (!sharedFontCssPromise) { if (!sharedFontCssPromise) {
@@ -408,6 +587,8 @@ export function createThemeRegistry(options: ThemeRegistryOptions) {
function invalidate() { function invalidate() {
cachedThemes = undefined; cachedThemes = undefined;
sharedFontCssPromise = undefined; sharedFontCssPromise = undefined;
cachedFontPacks = undefined;
reportedFontPackDiagnostics.clear();
} }
async function get(themeId: string) { async function get(themeId: string) {
@@ -427,15 +608,67 @@ export function createThemeRegistry(options: ThemeRegistryOptions) {
? [theme.manifest.print] ? [theme.manifest.print]
: []) : [])
]; ];
const css = await Promise.all( const [css, fontPacks] = await Promise.all([
Promise.all(
cssFiles.map((path) => cssFiles.map((path) =>
loadThemeCssFile(theme, path, createAssetUrl) loadThemeCssFile(theme, path, createAssetUrl)
) )
); ),
resolveThemeFontPacks(theme)
]);
if (theme.source === "bundled") { if (theme.source === "bundled") {
css.unshift(await loadSharedFontCss()); 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) { async function getAsset(themeId: string, assetPath: string) {
@@ -479,8 +712,21 @@ export function createThemeRegistry(options: ThemeRegistryOptions) {
return undefined; return undefined;
} }
const faces = theme.manifest.docxFonts?.faces ?? []; const faces = theme.manifest.docxFonts?.faces ?? [];
const fontPacks = await resolveThemeFontPacks(theme);
const fonts = await Promise.all( const fonts = await Promise.all(
faces.map(async (face) => { 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( const shared = face.source.startsWith(
sharedThemeAssetScheme sharedThemeAssetScheme
); );
@@ -519,6 +765,8 @@ export function createThemeRegistry(options: ThemeRegistryOptions) {
getAsset, getAsset,
getCss, getCss,
getDocxFonts, getDocxFonts,
getFontPackAsset,
getFontPackStatus,
invalidate, invalidate,
list list
}; };
@@ -13,6 +13,7 @@ import {
} from "node:fs/promises"; } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { createHash } from "node:crypto";
import { import {
ApplicationRequestError, ApplicationRequestError,
createApplicationService createApplicationService
@@ -126,6 +127,64 @@ async function createThemeFixture() {
return { bundledRoot, localRoot }; 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("共享应用服务", () => { describe("共享应用服务", () => {
it("渲染安全 Markdown 文档", async () => { it("渲染安全 Markdown 文档", async () => {
const roots = await createThemeFixture(); 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 () => { it("以稳定错误码拒绝非法 DOCX 配置和缺失主题", async () => {
const roots = await createThemeFixture(); const roots = await createThemeFixture();
const service = createApplicationService(roots); const service = createApplicationService(roots);
+2 -2
View File
@@ -9,9 +9,9 @@ import {
export const THEME_MANIFEST_VERSION = 1; export const THEME_MANIFEST_VERSION = 1;
export const MAXIMUM_DOCX_FONT_FACE_COUNT = 16; 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 = export const MAXIMUM_DOCX_FONT_TOTAL_SOURCE_BYTES =
32 * 1024 * 1024; 48 * 1024 * 1024;
const docxFontNameSchema = z const docxFontNameSchema = z
.string() .string()