feat: 建立可选字体包协议与安全注册器
This commit is contained in:
+23
-2
@@ -272,6 +272,21 @@ Panose、字符集、字体族、间距和 Unicode/代码页签名,写入
|
|||||||
保留使用字形子集,这是客户端写回行为,不影响导出文件的首次打开和
|
保留使用字形子集,这是客户端写回行为,不影响导出文件的首次打开和
|
||||||
编辑门禁。
|
编辑门禁。
|
||||||
|
|
||||||
|
阶段 12D-FP1 已建立独立 `packages/font-pack-registry`,冻结可选字体包
|
||||||
|
协议和安全发现边界。字体包使用
|
||||||
|
`root/<pack-id>/<semver>/font-pack.json` 固定布局,清单声明应用兼容
|
||||||
|
区间、许可文件、字体目标家族、别名、字重、样式以及同源 Web WOFF2 和
|
||||||
|
DOCX 静态 TrueType 资源。注册器按根目录顺序和最高兼容版本确定可用包,
|
||||||
|
使用完整 SemVer 比较,并对清单、普通文件、路径包含关系、符号链接、
|
||||||
|
单资源/总容量、许可文件和 SHA-256 执行严格校验;实际读取资源时会再次
|
||||||
|
验证大小与哈希,避免注册后文件被替换。候选解析按字体家族或别名、字重
|
||||||
|
和斜体精确匹配,支持调用方显式指定字体包优先级;根目录缺失、版本不
|
||||||
|
兼容、资源损坏、版本覆盖和字体面未提供均返回结构化诊断,缺少字体包
|
||||||
|
不会阻断后续安全降级。当前尚未接入主题、Preview/PDF、DOCX、Desktop
|
||||||
|
安装器或 Docker;FP2 将让 Web WOFF2 与 DOCX TTF 进入同一主题字体解析
|
||||||
|
结果,并把字体包指纹纳入缓存和导出诊断。全仓 454 项测试、类型检查和
|
||||||
|
生产构建通过。
|
||||||
|
|
||||||
## 2. 已完成
|
## 2. 已完成
|
||||||
|
|
||||||
### 2.1 项目骨架
|
### 2.1 项目骨架
|
||||||
@@ -1156,6 +1171,9 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
|
|||||||
- `packages/document-visual-diff` 负责 Word/WPS/PDF 页面快照、文本与
|
- `packages/document-visual-diff` 负责 Word/WPS/PDF 页面快照、文本与
|
||||||
栅格差异、可配置视觉门限以及 JSON/HTML 验收报告;该包仅用于开发和
|
栅格差异、可配置视觉门限以及 JSON/HTML 验收报告;该包仅用于开发和
|
||||||
发布验收,不进入应用运行时。
|
发布验收,不进入应用运行时。
|
||||||
|
- `packages/font-pack-registry` 负责可选字体包协议、版本兼容、安全发现、
|
||||||
|
资源哈希与容量门禁、确定性版本选择和逻辑字体面匹配;FP1 尚未将它
|
||||||
|
接入应用服务或发行包。
|
||||||
- `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
|
||||||
@@ -1215,8 +1233,11 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
|
|||||||
编辑互存;
|
编辑互存;
|
||||||
- 阶段 12D-R3:已完成独立 PDF/DOCX 视觉差异引擎、精确行距、字体
|
- 阶段 12D-R3:已完成独立 PDF/DOCX 视觉差异引擎、精确行距、字体
|
||||||
字重规范化及通用 SFNT 到 Word 字体元数据映射,并通过 Word/WPS
|
字重规范化及通用 SFNT 到 Word 字体元数据映射,并通过 Word/WPS
|
||||||
原生视觉与可编辑性验收;下一步先对齐可选字体包架构,再由 R4 完成
|
原生视觉与可编辑性验收;
|
||||||
14 套主题深度视觉回归;
|
- 阶段 12D-FP1:已完成可选字体包协议、安全注册器、版本/根目录优先级、
|
||||||
|
资源双重校验和字体候选匹配;下一步 FP2 接入 Preview/PDF 与 DOCX
|
||||||
|
同源字体解析,FP3 再实现独立 NSIS 字体包与 Docker 可选挂载,最终由
|
||||||
|
R4 完成 14 套主题深度视觉回归;
|
||||||
- 阶段 13:完成 Word/WPS 双向互存、外部主题兼容、体积和正式发布验收。
|
- 阶段 13:完成 Word/WPS 双向互存、外部主题兼容、体积和正式发布验收。
|
||||||
|
|
||||||
每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入
|
每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入
|
||||||
|
|||||||
Generated
+15
@@ -2199,6 +2199,10 @@
|
|||||||
"resolved": "packages/docx-theme-engine",
|
"resolved": "packages/docx-theme-engine",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@md-to-pdf/font-pack-registry": {
|
||||||
|
"resolved": "packages/font-pack-registry",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@md-to-pdf/markdown-echarts": {
|
"node_modules/@md-to-pdf/markdown-echarts": {
|
||||||
"resolved": "packages/markdown-echarts",
|
"resolved": "packages/markdown-echarts",
|
||||||
"link": true
|
"link": true
|
||||||
@@ -10018,6 +10022,17 @@
|
|||||||
"vitest": "^4.1.10"
|
"vitest": "^4.1.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"packages/font-pack-registry": {
|
||||||
|
"name": "@md-to-pdf/font-pack-registry",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dependencies": {
|
||||||
|
"zod": "^4.1.13"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"vitest": "^4.1.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"packages/markdown-echarts": {
|
"packages/markdown-echarts": {
|
||||||
"name": "@md-to-pdf/markdown-echarts",
|
"name": "@md-to-pdf/markdown-echarts",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
|||||||
+2
-2
@@ -33,8 +33,8 @@
|
|||||||
"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/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/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 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/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": {
|
||||||
"node": ">=22"
|
"node": ">=22"
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "@md-to-pdf/font-pack-registry",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"test": "vitest run",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"zod": "^4.1.13"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"vitest": "^4.1.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export * from "./registry.js";
|
||||||
|
export * from "./resolver.js";
|
||||||
|
export * from "./schema.js";
|
||||||
|
export * from "./types.js";
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import {
|
||||||
|
lstat,
|
||||||
|
readFile,
|
||||||
|
readdir,
|
||||||
|
realpath,
|
||||||
|
stat
|
||||||
|
} from "node:fs/promises";
|
||||||
|
import { isAbsolute, relative, resolve, sep } from "node:path";
|
||||||
|
import {
|
||||||
|
MAXIMUM_FONT_PACK_COUNT,
|
||||||
|
MAXIMUM_FONT_PACK_LICENSE_BYTES,
|
||||||
|
MAXIMUM_FONT_PACK_MANIFEST_BYTES,
|
||||||
|
compareSemanticVersions,
|
||||||
|
fontPackManifestSchema,
|
||||||
|
isAppVersionCompatible,
|
||||||
|
semanticVersionSchema,
|
||||||
|
type FontPackAsset,
|
||||||
|
type FontPackManifest
|
||||||
|
} from "./schema.js";
|
||||||
|
import type {
|
||||||
|
DiscoverFontPacksOptions,
|
||||||
|
FontPackDiagnostic,
|
||||||
|
FontPackRegistryResult,
|
||||||
|
InstalledFontPack,
|
||||||
|
InstalledFontPackAsset,
|
||||||
|
InstalledFontPackFace
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
function sha256(content: Uint8Array | string) {
|
||||||
|
return createHash("sha256").update(content).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isContainedPath(root: string, target: string) {
|
||||||
|
const path = relative(root, target);
|
||||||
|
return path !== ".." && !path.startsWith(`..${sep}`) && !isAbsolute(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readTrustedFile(
|
||||||
|
packDirectory: string,
|
||||||
|
asset: FontPackAsset | { path: string; bytes: number; sha256: string }
|
||||||
|
) {
|
||||||
|
const candidate = resolve(packDirectory, ...asset.path.split("/"));
|
||||||
|
if (!isContainedPath(packDirectory, candidate)) {
|
||||||
|
throw new Error(`资源越过字体包目录:${asset.path}`);
|
||||||
|
}
|
||||||
|
const linkStatus = await lstat(candidate);
|
||||||
|
if (linkStatus.isSymbolicLink() || !linkStatus.isFile()) {
|
||||||
|
throw new Error(`资源不是普通文件:${asset.path}`);
|
||||||
|
}
|
||||||
|
const realCandidate = await realpath(candidate);
|
||||||
|
if (!isContainedPath(packDirectory, realCandidate)) {
|
||||||
|
throw new Error(`资源真实路径越过字体包目录:${asset.path}`);
|
||||||
|
}
|
||||||
|
const fileStatus = await stat(realCandidate);
|
||||||
|
if (fileStatus.size !== asset.bytes) {
|
||||||
|
throw new Error(`资源大小不匹配:${asset.path}`);
|
||||||
|
}
|
||||||
|
const content = await readFile(realCandidate);
|
||||||
|
if (sha256(content) !== asset.sha256) {
|
||||||
|
throw new Error(`资源 SHA-256 不匹配:${asset.path}`);
|
||||||
|
}
|
||||||
|
return { absolutePath: realCandidate, content };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPack(
|
||||||
|
root: string,
|
||||||
|
rootIndex: number,
|
||||||
|
idDirectory: string,
|
||||||
|
versionDirectory: string,
|
||||||
|
appVersion: string
|
||||||
|
): Promise<InstalledFontPack> {
|
||||||
|
const packDirectory = await realpath(versionDirectory);
|
||||||
|
if (!isContainedPath(root, packDirectory)) {
|
||||||
|
throw new Error("字体包目录越过注册根目录");
|
||||||
|
}
|
||||||
|
const manifestPath = resolve(packDirectory, "font-pack.json");
|
||||||
|
const manifestStatus = await lstat(manifestPath);
|
||||||
|
if (
|
||||||
|
manifestStatus.isSymbolicLink() ||
|
||||||
|
!manifestStatus.isFile() ||
|
||||||
|
manifestStatus.size > MAXIMUM_FONT_PACK_MANIFEST_BYTES
|
||||||
|
) {
|
||||||
|
throw new Error("font-pack.json 不是受支持的普通文件");
|
||||||
|
}
|
||||||
|
const manifest = fontPackManifestSchema.parse(
|
||||||
|
JSON.parse(await readFile(manifestPath, "utf8")) as unknown
|
||||||
|
);
|
||||||
|
if (manifest.id !== idDirectory) {
|
||||||
|
throw new Error(`清单 ID 与目录不一致:${manifest.id} != ${idDirectory}`);
|
||||||
|
}
|
||||||
|
const versionName = versionDirectory.split(/[\\/]/u).at(-1);
|
||||||
|
if (manifest.version !== versionName) {
|
||||||
|
throw new Error(`清单版本与目录不一致:${manifest.version} != ${versionName}`);
|
||||||
|
}
|
||||||
|
if (!isAppVersionCompatible(appVersion, manifest.compatibility)) {
|
||||||
|
throw Object.assign(new Error("字体包与当前应用版本不兼容"), {
|
||||||
|
code: "PACK_INCOMPATIBLE"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const license = await readTrustedFile(packDirectory, {
|
||||||
|
path: manifest.licenseFile,
|
||||||
|
bytes: manifest.licenseBytes,
|
||||||
|
sha256: manifest.licenseSha256
|
||||||
|
});
|
||||||
|
if (license.content.byteLength > MAXIMUM_FONT_PACK_LICENSE_BYTES) {
|
||||||
|
throw new Error("字体包许可文件超过大小限制");
|
||||||
|
}
|
||||||
|
const faces: InstalledFontPackFace[] = [];
|
||||||
|
for (const face of manifest.faces) {
|
||||||
|
const web = await readTrustedFile(packDirectory, face.web);
|
||||||
|
const docx = await readTrustedFile(packDirectory, face.docx);
|
||||||
|
faces.push({
|
||||||
|
...face,
|
||||||
|
web: {
|
||||||
|
...face.web,
|
||||||
|
absolutePath: web.absolutePath,
|
||||||
|
packDirectory
|
||||||
|
},
|
||||||
|
docx: {
|
||||||
|
...face.docx,
|
||||||
|
absolutePath: docx.absolutePath,
|
||||||
|
packDirectory
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const fingerprint = sha256(
|
||||||
|
JSON.stringify({
|
||||||
|
manifestVersion: manifest.manifestVersion,
|
||||||
|
id: manifest.id,
|
||||||
|
version: manifest.version,
|
||||||
|
licenseSha256: manifest.licenseSha256,
|
||||||
|
faces: manifest.faces.map((face) => ({
|
||||||
|
id: face.id,
|
||||||
|
targets: [...face.targets].sort(),
|
||||||
|
weight: face.weight,
|
||||||
|
style: face.style,
|
||||||
|
web: face.web.sha256,
|
||||||
|
docx: face.docx.sha256
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...manifest,
|
||||||
|
root,
|
||||||
|
directory: packDirectory,
|
||||||
|
rootIndex,
|
||||||
|
fingerprint,
|
||||||
|
faces
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function diagnostic(
|
||||||
|
code: FontPackDiagnostic["code"],
|
||||||
|
severity: FontPackDiagnostic["severity"],
|
||||||
|
message: string,
|
||||||
|
fields: Partial<Omit<FontPackDiagnostic, "code" | "severity" | "message">> = {}
|
||||||
|
): FontPackDiagnostic {
|
||||||
|
return { code, severity, message, ...fields };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function discoverFontPacks(
|
||||||
|
options: DiscoverFontPacksOptions
|
||||||
|
): Promise<FontPackRegistryResult> {
|
||||||
|
semanticVersionSchema.parse(options.appVersion);
|
||||||
|
const diagnostics: FontPackDiagnostic[] = [];
|
||||||
|
const candidates: InstalledFontPack[] = [];
|
||||||
|
for (const [rootIndex, configuredRoot] of options.roots.entries()) {
|
||||||
|
let root: string;
|
||||||
|
const requestedRoot = resolve(configuredRoot);
|
||||||
|
try {
|
||||||
|
const requestedStatus = await lstat(requestedRoot);
|
||||||
|
if (requestedStatus.isSymbolicLink() || !requestedStatus.isDirectory()) {
|
||||||
|
diagnostics.push(
|
||||||
|
diagnostic("ROOT_INVALID", "error", "字体包根目录不是普通目录", {
|
||||||
|
root: requestedRoot
|
||||||
|
})
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
root = await realpath(requestedRoot);
|
||||||
|
} catch {
|
||||||
|
diagnostics.push(
|
||||||
|
diagnostic("ROOT_NOT_FOUND", "info", "字体包根目录不存在", {
|
||||||
|
root: requestedRoot
|
||||||
|
})
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const rootStatus = await lstat(root);
|
||||||
|
if (rootStatus.isSymbolicLink() || !rootStatus.isDirectory()) {
|
||||||
|
diagnostics.push(
|
||||||
|
diagnostic("ROOT_INVALID", "error", "字体包根目录不是普通目录", { root })
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const idEntries = (await readdir(root, { withFileTypes: true })).sort((a, b) =>
|
||||||
|
a.name.localeCompare(b.name, "en")
|
||||||
|
);
|
||||||
|
for (const idEntry of idEntries) {
|
||||||
|
if (!idEntry.isDirectory() || idEntry.isSymbolicLink()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const idDirectory = resolve(root, idEntry.name);
|
||||||
|
const versionEntries = (await readdir(idDirectory, { withFileTypes: true })).sort(
|
||||||
|
(a, b) => a.name.localeCompare(b.name, "en")
|
||||||
|
);
|
||||||
|
for (const versionEntry of versionEntries) {
|
||||||
|
if (
|
||||||
|
!versionEntry.isDirectory() ||
|
||||||
|
versionEntry.isSymbolicLink() ||
|
||||||
|
!semanticVersionSchema.safeParse(versionEntry.name).success
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
candidates.push(
|
||||||
|
await loadPack(
|
||||||
|
root,
|
||||||
|
rootIndex,
|
||||||
|
idEntry.name,
|
||||||
|
resolve(idDirectory, versionEntry.name),
|
||||||
|
options.appVersion
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const incompatible =
|
||||||
|
error instanceof Error &&
|
||||||
|
"code" in error &&
|
||||||
|
error.code === "PACK_INCOMPATIBLE";
|
||||||
|
diagnostics.push(
|
||||||
|
diagnostic(
|
||||||
|
incompatible ? "PACK_INCOMPATIBLE" : "MANIFEST_INVALID",
|
||||||
|
incompatible ? "warning" : "error",
|
||||||
|
error instanceof Error ? error.message : "字体包加载失败",
|
||||||
|
{
|
||||||
|
root,
|
||||||
|
packId: idEntry.name,
|
||||||
|
packVersion: versionEntry.name
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const selected = new Map<string, InstalledFontPack>();
|
||||||
|
for (const candidate of candidates.sort((left, right) => {
|
||||||
|
if (left.rootIndex !== right.rootIndex) {
|
||||||
|
return left.rootIndex - right.rootIndex;
|
||||||
|
}
|
||||||
|
const idOrder = left.id.localeCompare(right.id, "en");
|
||||||
|
return idOrder !== 0
|
||||||
|
? idOrder
|
||||||
|
: compareSemanticVersions(right.version, left.version);
|
||||||
|
})) {
|
||||||
|
const existing = selected.get(candidate.id);
|
||||||
|
if (!existing) {
|
||||||
|
selected.set(candidate.id, candidate);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
diagnostics.push(
|
||||||
|
diagnostic("PACK_SHADOWED", "info", "字体包版本被更高优先级版本覆盖", {
|
||||||
|
root: candidate.root,
|
||||||
|
packId: candidate.id,
|
||||||
|
packVersion: candidate.version
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const packs = [...selected.values()];
|
||||||
|
if (packs.length > MAXIMUM_FONT_PACK_COUNT) {
|
||||||
|
diagnostics.push(
|
||||||
|
diagnostic("PACK_LIMIT_EXCEEDED", "error", "可用字体包数量超过限制")
|
||||||
|
);
|
||||||
|
packs.length = MAXIMUM_FONT_PACK_COUNT;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
packs,
|
||||||
|
diagnostics,
|
||||||
|
fingerprint: sha256(
|
||||||
|
packs.map((pack) => `${pack.id}@${pack.version}:${pack.fingerprint}`).join("\n")
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readInstalledFontPackAsset(
|
||||||
|
asset: InstalledFontPackAsset
|
||||||
|
) {
|
||||||
|
const content = (
|
||||||
|
await readTrustedFile(asset.packDirectory, {
|
||||||
|
path: asset.path,
|
||||||
|
bytes: asset.bytes,
|
||||||
|
sha256: asset.sha256
|
||||||
|
})
|
||||||
|
).content;
|
||||||
|
return new Uint8Array(content);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import type {
|
||||||
|
FontPackDiagnostic,
|
||||||
|
FontPackFaceRequest,
|
||||||
|
FontPackFaceResolution,
|
||||||
|
InstalledFontPack,
|
||||||
|
ResolveFontPackFacesOptions
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
function normalizeFontFamily(value: string) {
|
||||||
|
return value.trim().toLocaleLowerCase("en-US");
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderedPacks(
|
||||||
|
packs: readonly InstalledFontPack[],
|
||||||
|
preferredPackIds: readonly string[]
|
||||||
|
) {
|
||||||
|
const preference = new Map(
|
||||||
|
preferredPackIds.map((id, index) => [id, index])
|
||||||
|
);
|
||||||
|
return [...packs].sort((left, right) => {
|
||||||
|
const leftRank = preference.get(left.id) ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
const rightRank = preference.get(right.id) ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
return leftRank !== rightRank
|
||||||
|
? leftRank - rightRank
|
||||||
|
: left.id.localeCompare(right.id, "en");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveFontPackFaces(
|
||||||
|
requests: readonly FontPackFaceRequest[],
|
||||||
|
packs: readonly InstalledFontPack[],
|
||||||
|
options: ResolveFontPackFacesOptions = {}
|
||||||
|
): FontPackFaceResolution {
|
||||||
|
const resolved: FontPackFaceResolution["resolved"] = [];
|
||||||
|
const unmatched: FontPackFaceRequest[] = [];
|
||||||
|
const diagnostics: FontPackDiagnostic[] = [];
|
||||||
|
const candidates = orderedPacks(packs, options.preferredPackIds ?? []);
|
||||||
|
for (const request of requests) {
|
||||||
|
const requestNames = new Set(
|
||||||
|
[request.family, ...(request.aliases ?? [])].map(normalizeFontFamily)
|
||||||
|
);
|
||||||
|
let match: FontPackFaceResolution["resolved"][number] | undefined;
|
||||||
|
for (const pack of candidates) {
|
||||||
|
const face = pack.faces.find(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.weight === request.weight &&
|
||||||
|
candidate.style === request.style &&
|
||||||
|
candidate.targets.some((target) =>
|
||||||
|
requestNames.has(normalizeFontFamily(target))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (face) {
|
||||||
|
match = {
|
||||||
|
request,
|
||||||
|
pack,
|
||||||
|
face,
|
||||||
|
matchedFamily:
|
||||||
|
face.targets.find((target) =>
|
||||||
|
requestNames.has(normalizeFontFamily(target))
|
||||||
|
) ?? request.family
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (match) {
|
||||||
|
resolved.push(match);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
unmatched.push(request);
|
||||||
|
if (options.reportMissing) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: "FONT_FACE_NOT_PROVIDED",
|
||||||
|
severity: "info",
|
||||||
|
message: `没有字体包提供 ${request.family} ${request.weight} ${request.style}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { resolved, unmatched, diagnostics };
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const FONT_PACK_MANIFEST_VERSION = 1;
|
||||||
|
export const MAXIMUM_FONT_PACK_COUNT = 8;
|
||||||
|
export const MAXIMUM_FONT_PACK_FACE_COUNT = 16;
|
||||||
|
export const MAXIMUM_FONT_PACK_MANIFEST_BYTES = 128 * 1024;
|
||||||
|
export const MAXIMUM_FONT_PACK_LICENSE_BYTES = 256 * 1024;
|
||||||
|
export const MAXIMUM_FONT_PACK_WEB_FONT_BYTES = 8 * 1024 * 1024;
|
||||||
|
export const MAXIMUM_FONT_PACK_DOCX_FONT_BYTES = 16 * 1024 * 1024;
|
||||||
|
export const MAXIMUM_FONT_PACK_TOTAL_BYTES = 96 * 1024 * 1024;
|
||||||
|
|
||||||
|
const semanticVersionPattern =
|
||||||
|
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/u;
|
||||||
|
const identifierPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
||||||
|
const sha256Pattern = /^[a-f0-9]{64}$/u;
|
||||||
|
const fontNamePattern = /^[\p{L}\p{N}\s._-]+$/u;
|
||||||
|
|
||||||
|
export const semanticVersionSchema = z
|
||||||
|
.string()
|
||||||
|
.max(100)
|
||||||
|
.regex(semanticVersionPattern, "版本号必须是完整 SemVer")
|
||||||
|
.refine(
|
||||||
|
(value) => {
|
||||||
|
const prerelease = semanticVersionPattern.exec(value)?.[4];
|
||||||
|
return !prerelease
|
||||||
|
? true
|
||||||
|
: prerelease
|
||||||
|
.split(".")
|
||||||
|
.every((part) => !/^\d+$/u.test(part) || part === "0" || !part.startsWith("0"));
|
||||||
|
},
|
||||||
|
"SemVer 预发布数字标识不能包含前导零"
|
||||||
|
);
|
||||||
|
|
||||||
|
const safeRelativePathSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1)
|
||||||
|
.max(300)
|
||||||
|
.refine(
|
||||||
|
(value) =>
|
||||||
|
!value.includes("\\") &&
|
||||||
|
!value.includes("\0") &&
|
||||||
|
!value.startsWith("/") &&
|
||||||
|
!value.split("/").some((segment) => segment === ".." || segment === ""),
|
||||||
|
"资源必须使用安全的 POSIX 相对路径"
|
||||||
|
);
|
||||||
|
|
||||||
|
const fontAssetSchema = z
|
||||||
|
.object({
|
||||||
|
path: safeRelativePathSchema,
|
||||||
|
sha256: z.string().regex(sha256Pattern),
|
||||||
|
bytes: z.number().int().positive()
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const webFontAssetSchema = fontAssetSchema
|
||||||
|
.extend({
|
||||||
|
format: z.literal("woff2"),
|
||||||
|
bytes: z.number().int().positive().max(MAXIMUM_FONT_PACK_WEB_FONT_BYTES)
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.refine((asset) => asset.path.toLowerCase().endsWith(".woff2"), {
|
||||||
|
message: "Web 字体资源必须使用 .woff2 扩展名",
|
||||||
|
path: ["path"]
|
||||||
|
});
|
||||||
|
|
||||||
|
const docxFontAssetSchema = fontAssetSchema
|
||||||
|
.extend({
|
||||||
|
format: z.literal("truetype"),
|
||||||
|
bytes: z.number().int().positive().max(MAXIMUM_FONT_PACK_DOCX_FONT_BYTES)
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.refine((asset) => asset.path.toLowerCase().endsWith(".ttf"), {
|
||||||
|
message: "DOCX 字体资源必须使用静态 .ttf 文件",
|
||||||
|
path: ["path"]
|
||||||
|
});
|
||||||
|
|
||||||
|
const fontFamilySchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1)
|
||||||
|
.max(100)
|
||||||
|
.regex(fontNamePattern, "字体家族名称包含不支持的字符");
|
||||||
|
|
||||||
|
export const fontPackFaceSchema = z
|
||||||
|
.object({
|
||||||
|
id: z.string().regex(identifierPattern),
|
||||||
|
targets: z.array(fontFamilySchema).min(1).max(32),
|
||||||
|
weight: z.number().int().min(100).max(900).multipleOf(100),
|
||||||
|
style: z.enum(["normal", "italic"]),
|
||||||
|
web: webFontAssetSchema,
|
||||||
|
docx: docxFontAssetSchema
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export const fontPackManifestSchema = z
|
||||||
|
.object({
|
||||||
|
manifestVersion: z.literal(FONT_PACK_MANIFEST_VERSION),
|
||||||
|
id: z.string().regex(identifierPattern),
|
||||||
|
version: semanticVersionSchema,
|
||||||
|
name: z.string().trim().min(1).max(100),
|
||||||
|
description: z.string().trim().max(500),
|
||||||
|
license: z.string().trim().min(1).max(100),
|
||||||
|
licenseFile: safeRelativePathSchema,
|
||||||
|
licenseSha256: z.string().regex(sha256Pattern),
|
||||||
|
licenseBytes: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
|
.max(MAXIMUM_FONT_PACK_LICENSE_BYTES),
|
||||||
|
compatibility: z
|
||||||
|
.object({
|
||||||
|
minimumAppVersion: semanticVersionSchema,
|
||||||
|
maximumAppVersionExclusive: semanticVersionSchema.optional()
|
||||||
|
})
|
||||||
|
.strict(),
|
||||||
|
faces: z.array(fontPackFaceSchema).min(1).max(MAXIMUM_FONT_PACK_FACE_COUNT)
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.superRefine((manifest, context) => {
|
||||||
|
if (
|
||||||
|
manifest.compatibility.maximumAppVersionExclusive !== undefined &&
|
||||||
|
compareSemanticVersions(
|
||||||
|
manifest.compatibility.maximumAppVersionExclusive,
|
||||||
|
manifest.compatibility.minimumAppVersion
|
||||||
|
) <= 0
|
||||||
|
) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "应用兼容上限必须大于最低版本",
|
||||||
|
path: ["compatibility", "maximumAppVersionExclusive"]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const ids = new Set<string>();
|
||||||
|
const identities = new Set<string>();
|
||||||
|
const paths = new Set<string>([manifest.licenseFile.toLowerCase()]);
|
||||||
|
for (const [index, face] of manifest.faces.entries()) {
|
||||||
|
if (
|
||||||
|
new Set(face.targets.map((target) => target.toLowerCase())).size !==
|
||||||
|
face.targets.length
|
||||||
|
) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "字体面目标家族不能重复",
|
||||||
|
path: ["faces", index, "targets"]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (ids.has(face.id)) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: `字体面 ID 重复:${face.id}`,
|
||||||
|
path: ["faces", index, "id"]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ids.add(face.id);
|
||||||
|
const identity = `${face.targets.map((target) => target.toLowerCase()).sort().join("|")}:${face.weight}:${face.style}`;
|
||||||
|
if (identities.has(identity)) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "字体面目标、字重和样式重复",
|
||||||
|
path: ["faces", index]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
identities.add(identity);
|
||||||
|
for (const [kind, path] of [
|
||||||
|
["web", face.web.path],
|
||||||
|
["docx", face.docx.path]
|
||||||
|
] as const) {
|
||||||
|
const normalized = path.toLowerCase();
|
||||||
|
if (paths.has(normalized)) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: `字体包资源路径重复:${path}`,
|
||||||
|
path: ["faces", index, kind, "path"]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
paths.add(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const totalBytes = manifest.faces.reduce(
|
||||||
|
(total, face) => total + face.web.bytes + face.docx.bytes,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
if (totalBytes > MAXIMUM_FONT_PACK_TOTAL_BYTES) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "字体包声明资源总大小超过限制",
|
||||||
|
path: ["faces"]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export type FontPackManifest = z.infer<typeof fontPackManifestSchema>;
|
||||||
|
export type FontPackFace = z.infer<typeof fontPackFaceSchema>;
|
||||||
|
export type FontPackAsset = FontPackFace["web"] | FontPackFace["docx"];
|
||||||
|
|
||||||
|
export function parseSemanticVersion(value: string) {
|
||||||
|
const match = semanticVersionPattern.exec(value);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error(`无效的 SemVer:${value}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
major: Number(match[1]),
|
||||||
|
minor: Number(match[2]),
|
||||||
|
patch: Number(match[3]),
|
||||||
|
prerelease: match[4]?.split(".") ?? []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function comparePrerelease(left: string[], right: string[]) {
|
||||||
|
if (left.length === 0 || right.length === 0) {
|
||||||
|
return left.length === right.length ? 0 : left.length === 0 ? 1 : -1;
|
||||||
|
}
|
||||||
|
const length = Math.max(left.length, right.length);
|
||||||
|
for (let index = 0; index < length; index += 1) {
|
||||||
|
const leftPart = left[index];
|
||||||
|
const rightPart = right[index];
|
||||||
|
if (leftPart === undefined || rightPart === undefined) {
|
||||||
|
return leftPart === rightPart ? 0 : leftPart === undefined ? -1 : 1;
|
||||||
|
}
|
||||||
|
if (leftPart === rightPart) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const leftNumber = /^\d+$/u.test(leftPart) ? Number(leftPart) : undefined;
|
||||||
|
const rightNumber = /^\d+$/u.test(rightPart) ? Number(rightPart) : undefined;
|
||||||
|
if (leftNumber !== undefined && rightNumber !== undefined) {
|
||||||
|
return leftNumber < rightNumber ? -1 : 1;
|
||||||
|
}
|
||||||
|
if (leftNumber !== undefined || rightNumber !== undefined) {
|
||||||
|
return leftNumber !== undefined ? -1 : 1;
|
||||||
|
}
|
||||||
|
return leftPart < rightPart ? -1 : 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compareSemanticVersions(left: string, right: string) {
|
||||||
|
const a = parseSemanticVersion(left);
|
||||||
|
const b = parseSemanticVersion(right);
|
||||||
|
for (const key of ["major", "minor", "patch"] as const) {
|
||||||
|
if (a[key] !== b[key]) {
|
||||||
|
return a[key] < b[key] ? -1 : 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return comparePrerelease(a.prerelease, b.prerelease);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAppVersionCompatible(
|
||||||
|
appVersion: string,
|
||||||
|
compatibility: FontPackManifest["compatibility"]
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
compareSemanticVersions(appVersion, compatibility.minimumAppVersion) >= 0 &&
|
||||||
|
(compatibility.maximumAppVersionExclusive === undefined ||
|
||||||
|
compareSemanticVersions(
|
||||||
|
appVersion,
|
||||||
|
compatibility.maximumAppVersionExclusive
|
||||||
|
) < 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import type { FontPackAsset, FontPackFace, FontPackManifest } from "./schema.js";
|
||||||
|
|
||||||
|
export type FontPackDiagnosticSeverity = "info" | "warning" | "error";
|
||||||
|
|
||||||
|
export type FontPackDiagnosticCode =
|
||||||
|
| "ROOT_NOT_FOUND"
|
||||||
|
| "ROOT_INVALID"
|
||||||
|
| "PACK_LIMIT_EXCEEDED"
|
||||||
|
| "PACK_LAYOUT_INVALID"
|
||||||
|
| "MANIFEST_INVALID"
|
||||||
|
| "PACK_INCOMPATIBLE"
|
||||||
|
| "PACK_SHADOWED"
|
||||||
|
| "RESOURCE_INVALID"
|
||||||
|
| "FONT_FACE_NOT_PROVIDED";
|
||||||
|
|
||||||
|
export interface FontPackDiagnostic {
|
||||||
|
code: FontPackDiagnosticCode;
|
||||||
|
severity: FontPackDiagnosticSeverity;
|
||||||
|
message: string;
|
||||||
|
root?: string;
|
||||||
|
packId?: string;
|
||||||
|
packVersion?: string;
|
||||||
|
faceId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InstalledFontPackAsset = FontPackAsset & {
|
||||||
|
absolutePath: string;
|
||||||
|
packDirectory: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface InstalledFontPackFace
|
||||||
|
extends Omit<FontPackFace, "web" | "docx"> {
|
||||||
|
web: InstalledFontPackAsset & { format: "woff2" };
|
||||||
|
docx: InstalledFontPackAsset & { format: "truetype" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstalledFontPack
|
||||||
|
extends Omit<FontPackManifest, "faces"> {
|
||||||
|
root: string;
|
||||||
|
directory: string;
|
||||||
|
rootIndex: number;
|
||||||
|
fingerprint: string;
|
||||||
|
faces: InstalledFontPackFace[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FontPackRegistryResult {
|
||||||
|
packs: InstalledFontPack[];
|
||||||
|
diagnostics: FontPackDiagnostic[];
|
||||||
|
fingerprint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FontPackFaceRequest {
|
||||||
|
family: string;
|
||||||
|
aliases?: readonly string[];
|
||||||
|
weight: number;
|
||||||
|
style: "normal" | "italic";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedFontPackFace {
|
||||||
|
request: FontPackFaceRequest;
|
||||||
|
pack: InstalledFontPack;
|
||||||
|
face: InstalledFontPackFace;
|
||||||
|
matchedFamily: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FontPackFaceResolution {
|
||||||
|
resolved: ResolvedFontPackFace[];
|
||||||
|
unmatched: FontPackFaceRequest[];
|
||||||
|
diagnostics: FontPackDiagnostic[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscoverFontPacksOptions {
|
||||||
|
roots: readonly string[];
|
||||||
|
appVersion: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolveFontPackFacesOptions {
|
||||||
|
preferredPackIds?: readonly string[];
|
||||||
|
reportMissing?: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
export function hash(content: Uint8Array | string) {
|
||||||
|
return createHash("sha256").update(content).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeFontPack(
|
||||||
|
root: string,
|
||||||
|
options: {
|
||||||
|
id?: string;
|
||||||
|
version?: string;
|
||||||
|
minimumAppVersion?: string;
|
||||||
|
maximumAppVersionExclusive?: string;
|
||||||
|
target?: string;
|
||||||
|
web?: Uint8Array;
|
||||||
|
docx?: Uint8Array;
|
||||||
|
corruptWebHash?: boolean;
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
|
const id = options.id ?? "official-cjk";
|
||||||
|
const version = options.version ?? "1.0.0";
|
||||||
|
const directory = join(root, id, version);
|
||||||
|
const web = options.web ?? new Uint8Array([1, 2, 3]);
|
||||||
|
const docx = options.docx ?? new Uint8Array([4, 5, 6, 7]);
|
||||||
|
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");
|
||||||
|
const maximum = options.maximumAppVersionExclusive
|
||||||
|
? { maximumAppVersionExclusive: options.maximumAppVersionExclusive }
|
||||||
|
: {};
|
||||||
|
await writeFile(
|
||||||
|
join(directory, "font-pack.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
manifestVersion: 1,
|
||||||
|
id,
|
||||||
|
version,
|
||||||
|
name: "原生中文字体包",
|
||||||
|
description: "测试字体包",
|
||||||
|
license: "OFL-1.1",
|
||||||
|
licenseFile: "OFL.txt",
|
||||||
|
licenseSha256: hash(license),
|
||||||
|
licenseBytes: Buffer.byteLength(license),
|
||||||
|
compatibility: {
|
||||||
|
minimumAppVersion: options.minimumAppVersion ?? "0.6.0",
|
||||||
|
...maximum
|
||||||
|
},
|
||||||
|
faces: [
|
||||||
|
{
|
||||||
|
id: "serif-regular",
|
||||||
|
targets: [options.target ?? "Mdpdf Fandol Song", "FandolSong"],
|
||||||
|
weight: 400,
|
||||||
|
style: "normal",
|
||||||
|
web: {
|
||||||
|
path: "fonts/serif.woff2",
|
||||||
|
format: "woff2",
|
||||||
|
bytes: web.byteLength,
|
||||||
|
sha256: options.corruptWebHash ? "0".repeat(64) : hash(web)
|
||||||
|
},
|
||||||
|
docx: {
|
||||||
|
path: "fonts/serif.ttf",
|
||||||
|
format: "truetype",
|
||||||
|
bytes: docx.byteLength,
|
||||||
|
sha256: hash(docx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
return directory;
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
discoverFontPacks,
|
||||||
|
readInstalledFontPackAsset
|
||||||
|
} from "../src/index.js";
|
||||||
|
import { writeFontPack } from "./fixtures.js";
|
||||||
|
|
||||||
|
const temporaryDirectories: string[] = [];
|
||||||
|
|
||||||
|
async function temporaryDirectory() {
|
||||||
|
const directory = await mkdtemp(join(tmpdir(), "mdpdf-font-pack-"));
|
||||||
|
temporaryDirectories.push(directory);
|
||||||
|
return directory;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
temporaryDirectories.splice(0).map((directory) =>
|
||||||
|
rm(directory, { recursive: true, force: true })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("字体包注册器", () => {
|
||||||
|
it("发现并验证兼容字体包,生成稳定指纹", async () => {
|
||||||
|
const root = await temporaryDirectory();
|
||||||
|
await writeFontPack(root);
|
||||||
|
const first = await discoverFontPacks({ roots: [root], appVersion: "0.6.0" });
|
||||||
|
const second = await discoverFontPacks({ roots: [root], appVersion: "0.6.0" });
|
||||||
|
expect(first.packs).toHaveLength(1);
|
||||||
|
expect(first.diagnostics).toEqual([]);
|
||||||
|
expect(first.fingerprint).toBe(second.fingerprint);
|
||||||
|
expect(await readInstalledFontPackAsset(first.packs[0]!.faces[0]!.docx)).toEqual(
|
||||||
|
new Uint8Array([4, 5, 6, 7])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("同一根目录选择最高兼容版本并记录被覆盖版本", async () => {
|
||||||
|
const root = await temporaryDirectory();
|
||||||
|
await writeFontPack(root, { version: "1.0.0" });
|
||||||
|
await writeFontPack(root, { version: "1.2.0" });
|
||||||
|
const result = await discoverFontPacks({ roots: [root], appVersion: "0.6.0" });
|
||||||
|
expect(result.packs[0]?.version).toBe("1.2.0");
|
||||||
|
expect(result.diagnostics.map((item) => item.code)).toContain("PACK_SHADOWED");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("多根目录优先使用靠前根目录中的包", async () => {
|
||||||
|
const firstRoot = await temporaryDirectory();
|
||||||
|
const secondRoot = await temporaryDirectory();
|
||||||
|
await writeFontPack(firstRoot, { version: "1.0.0" });
|
||||||
|
await writeFontPack(secondRoot, { version: "2.0.0" });
|
||||||
|
const result = await discoverFontPacks({
|
||||||
|
roots: [firstRoot, secondRoot],
|
||||||
|
appVersion: "0.6.0"
|
||||||
|
});
|
||||||
|
expect(result.packs[0]?.version).toBe("1.0.0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("隔离不兼容和哈希错误的字体包", async () => {
|
||||||
|
const root = await temporaryDirectory();
|
||||||
|
await writeFontPack(root, {
|
||||||
|
id: "future-pack",
|
||||||
|
minimumAppVersion: "0.7.0"
|
||||||
|
});
|
||||||
|
await writeFontPack(root, { id: "broken-pack", corruptWebHash: true });
|
||||||
|
const result = await discoverFontPacks({ roots: [root], appVersion: "0.6.0" });
|
||||||
|
expect(result.packs).toEqual([]);
|
||||||
|
expect(result.diagnostics.map((item) => item.code).sort()).toEqual([
|
||||||
|
"MANIFEST_INVALID",
|
||||||
|
"PACK_INCOMPATIBLE"
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("读取时重新验证资源,拒绝注册后被替换的文件", async () => {
|
||||||
|
const root = await temporaryDirectory();
|
||||||
|
await writeFontPack(root);
|
||||||
|
const result = await discoverFontPacks({ roots: [root], appVersion: "0.6.0" });
|
||||||
|
const asset = result.packs[0]!.faces[0]!.web;
|
||||||
|
await writeFile(asset.absolutePath, new Uint8Array([9, 9, 9]));
|
||||||
|
await expect(readInstalledFontPackAsset(asset)).rejects.toThrow("SHA-256");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("根目录缺失时返回信息诊断而不阻断", async () => {
|
||||||
|
const root = join(await temporaryDirectory(), "missing");
|
||||||
|
const result = await discoverFontPacks({ roots: [root], appVersion: "0.6.0" });
|
||||||
|
expect(result.packs).toEqual([]);
|
||||||
|
expect(result.diagnostics[0]?.code).toBe("ROOT_NOT_FOUND");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
discoverFontPacks,
|
||||||
|
resolveFontPackFaces
|
||||||
|
} from "../src/index.js";
|
||||||
|
import { writeFontPack } from "./fixtures.js";
|
||||||
|
|
||||||
|
const temporaryDirectories: string[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
temporaryDirectories.splice(0).map((directory) =>
|
||||||
|
rm(directory, { recursive: true, force: true })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("字体包候选解析", () => {
|
||||||
|
it("按家族别名、字重和样式精确匹配", async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "mdpdf-font-resolve-"));
|
||||||
|
temporaryDirectories.push(root);
|
||||||
|
await writeFontPack(root);
|
||||||
|
const registry = await discoverFontPacks({ roots: [root], appVersion: "0.6.0" });
|
||||||
|
const result = resolveFontPackFaces(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
family: "Theme Song",
|
||||||
|
aliases: ["FandolSong"],
|
||||||
|
weight: 400,
|
||||||
|
style: "normal"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
family: "Theme Song",
|
||||||
|
aliases: ["FandolSong"],
|
||||||
|
weight: 700,
|
||||||
|
style: "normal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
registry.packs,
|
||||||
|
{ reportMissing: true }
|
||||||
|
);
|
||||||
|
expect(result.resolved[0]?.matchedFamily).toBe("FandolSong");
|
||||||
|
expect(result.unmatched).toHaveLength(1);
|
||||||
|
expect(result.diagnostics[0]?.code).toBe("FONT_FACE_NOT_PROVIDED");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("允许调用方显式指定字体包优先级", async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "mdpdf-font-priority-"));
|
||||||
|
temporaryDirectories.push(root);
|
||||||
|
await writeFontPack(root, { id: "alpha-pack" });
|
||||||
|
await writeFontPack(root, { id: "preferred-pack" });
|
||||||
|
const registry = await discoverFontPacks({ roots: [root], appVersion: "0.6.0" });
|
||||||
|
const result = resolveFontPackFaces(
|
||||||
|
[{ family: "FandolSong", weight: 400, style: "normal" }],
|
||||||
|
registry.packs,
|
||||||
|
{ preferredPackIds: ["preferred-pack"] }
|
||||||
|
);
|
||||||
|
expect(result.resolved[0]?.pack.id).toBe("preferred-pack");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
compareSemanticVersions,
|
||||||
|
fontPackManifestSchema,
|
||||||
|
isAppVersionCompatible,
|
||||||
|
semanticVersionSchema
|
||||||
|
} from "../src/index.js";
|
||||||
|
|
||||||
|
describe("字体包协议", () => {
|
||||||
|
it("按 SemVer 规则比较版本并处理预发布版本", () => {
|
||||||
|
expect(compareSemanticVersions("1.0.0-beta.2", "1.0.0-beta.10")).toBeLessThan(0);
|
||||||
|
expect(compareSemanticVersions("1.0.0-rc.1", "1.0.0")).toBeLessThan(0);
|
||||||
|
expect(semanticVersionSchema.safeParse("1.0.0-01").success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("使用包含下限、不包含上限的应用兼容区间", () => {
|
||||||
|
const compatibility = {
|
||||||
|
minimumAppVersion: "0.6.0",
|
||||||
|
maximumAppVersionExclusive: "0.7.0"
|
||||||
|
};
|
||||||
|
expect(isAppVersionCompatible("0.6.0", compatibility)).toBe(true);
|
||||||
|
expect(isAppVersionCompatible("0.6.9", compatibility)).toBe(true);
|
||||||
|
expect(isAppVersionCompatible("0.7.0", compatibility)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("拒绝越界资源路径和倒置的兼容区间", () => {
|
||||||
|
const parsed = fontPackManifestSchema.safeParse({
|
||||||
|
manifestVersion: 1,
|
||||||
|
id: "official-cjk",
|
||||||
|
version: "1.0.0",
|
||||||
|
name: "测试",
|
||||||
|
description: "",
|
||||||
|
license: "OFL-1.1",
|
||||||
|
licenseFile: "../OFL.txt",
|
||||||
|
licenseSha256: "a".repeat(64),
|
||||||
|
licenseBytes: 10,
|
||||||
|
compatibility: {
|
||||||
|
minimumAppVersion: "0.7.0",
|
||||||
|
maximumAppVersionExclusive: "0.6.0"
|
||||||
|
},
|
||||||
|
faces: []
|
||||||
|
});
|
||||||
|
expect(parsed.success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist",
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"types": [
|
||||||
|
"node"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user