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");
|
||||
|
||||
+26
-5
@@ -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 双向互存、外部主题兼容、体积和正式发布验收。
|
||||
|
||||
每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入
|
||||
|
||||
Generated
+1
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -47,6 +47,7 @@ export {
|
||||
} from "./docx-export-service.js";
|
||||
export {
|
||||
createThemeRegistry,
|
||||
type ThemeFontPackStatus,
|
||||
type ThemeRecord,
|
||||
type ThemeRegistryOptions
|
||||
} from "./theme-registry.js";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user