diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 26531ca..7289553 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -272,6 +272,21 @@ Panose、字符集、字体族、间距和 Unicode/代码页签名,写入 保留使用字形子集,这是客户端写回行为,不影响导出文件的首次打开和 编辑门禁。 +阶段 12D-FP1 已建立独立 `packages/font-pack-registry`,冻结可选字体包 +协议和安全发现边界。字体包使用 +`root///font-pack.json` 固定布局,清单声明应用兼容 +区间、许可文件、字体目标家族、别名、字重、样式以及同源 Web WOFF2 和 +DOCX 静态 TrueType 资源。注册器按根目录顺序和最高兼容版本确定可用包, +使用完整 SemVer 比较,并对清单、普通文件、路径包含关系、符号链接、 +单资源/总容量、许可文件和 SHA-256 执行严格校验;实际读取资源时会再次 +验证大小与哈希,避免注册后文件被替换。候选解析按字体家族或别名、字重 +和斜体精确匹配,支持调用方显式指定字体包优先级;根目录缺失、版本不 +兼容、资源损坏、版本覆盖和字体面未提供均返回结构化诊断,缺少字体包 +不会阻断后续安全降级。当前尚未接入主题、Preview/PDF、DOCX、Desktop +安装器或 Docker;FP2 将让 Web WOFF2 与 DOCX TTF 进入同一主题字体解析 +结果,并把字体包指纹纳入缓存和导出诊断。全仓 454 项测试、类型检查和 +生产构建通过。 + ## 2. 已完成 ### 2.1 项目骨架 @@ -1156,6 +1171,9 @@ ECharts 第二阶段浏览器与 PDF 验证结果: - `packages/document-visual-diff` 负责 Word/WPS/PDF 页面快照、文本与 栅格差异、可配置视觉门限以及 JSON/HTML 验收报告;该包仅用于开发和 发布验收,不进入应用运行时。 +- `packages/font-pack-registry` 负责可选字体包协议、版本兼容、安全发现、 + 资源哈希与容量门禁、确定性版本选择和逻辑字体面匹配;FP1 尚未将它 + 接入应用服务或发行包。 - `packages/application/src/docx-export-service.ts` 负责 DOCX capability、 并发排队、总超时、取消、跨端转换编排、错误码和耗时诊断。 - `apps/desktop/src/desktop-application-controller.ts` 负责 Electron @@ -1215,8 +1233,11 @@ ECharts 第二阶段浏览器与 PDF 验证结果: 编辑互存; - 阶段 12D-R3:已完成独立 PDF/DOCX 视觉差异引擎、精确行距、字体 字重规范化及通用 SFNT 到 Word 字体元数据映射,并通过 Word/WPS - 原生视觉与可编辑性验收;下一步先对齐可选字体包架构,再由 R4 完成 - 14 套主题深度视觉回归; + 原生视觉与可编辑性验收; +- 阶段 12D-FP1:已完成可选字体包协议、安全注册器、版本/根目录优先级、 + 资源双重校验和字体候选匹配;下一步 FP2 接入 Preview/PDF 与 DOCX + 同源字体解析,FP3 再实现独立 NSIS 字体包与 Docker 可选挂载,最终由 + R4 完成 14 套主题深度视觉回归; - 阶段 13:完成 Word/WPS 双向互存、外部主题兼容、体积和正式发布验收。 每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入 diff --git a/package-lock.json b/package-lock.json index 28836eb..a295fd4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2199,6 +2199,10 @@ "resolved": "packages/docx-theme-engine", "link": true }, + "node_modules/@md-to-pdf/font-pack-registry": { + "resolved": "packages/font-pack-registry", + "link": true + }, "node_modules/@md-to-pdf/markdown-echarts": { "resolved": "packages/markdown-echarts", "link": true @@ -10018,6 +10022,17 @@ "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": { "name": "@md-to-pdf/markdown-echarts", "version": "0.1.0", diff --git a/package.json b/package.json index 1384352..b1ad73e 100644 --- a/package.json +++ b/package.json @@ -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-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/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" + "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/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": { "node": ">=22" diff --git a/packages/font-pack-registry/package.json b/packages/font-pack-registry/package.json new file mode 100644 index 0000000..bf4df95 --- /dev/null +++ b/packages/font-pack-registry/package.json @@ -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" + } +} diff --git a/packages/font-pack-registry/src/index.ts b/packages/font-pack-registry/src/index.ts new file mode 100644 index 0000000..5b64a49 --- /dev/null +++ b/packages/font-pack-registry/src/index.ts @@ -0,0 +1,4 @@ +export * from "./registry.js"; +export * from "./resolver.js"; +export * from "./schema.js"; +export * from "./types.js"; diff --git a/packages/font-pack-registry/src/registry.ts b/packages/font-pack-registry/src/registry.ts new file mode 100644 index 0000000..c6fbd91 --- /dev/null +++ b/packages/font-pack-registry/src/registry.ts @@ -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 { + 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> = {} +): FontPackDiagnostic { + return { code, severity, message, ...fields }; +} + +export async function discoverFontPacks( + options: DiscoverFontPacksOptions +): Promise { + 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(); + 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); +} diff --git a/packages/font-pack-registry/src/resolver.ts b/packages/font-pack-registry/src/resolver.ts new file mode 100644 index 0000000..d52bfe7 --- /dev/null +++ b/packages/font-pack-registry/src/resolver.ts @@ -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 }; +} diff --git a/packages/font-pack-registry/src/schema.ts b/packages/font-pack-registry/src/schema.ts new file mode 100644 index 0000000..25f603e --- /dev/null +++ b/packages/font-pack-registry/src/schema.ts @@ -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(); + const identities = new Set(); + const paths = new Set([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; +export type FontPackFace = z.infer; +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) + ); +} diff --git a/packages/font-pack-registry/src/types.ts b/packages/font-pack-registry/src/types.ts new file mode 100644 index 0000000..0d297a2 --- /dev/null +++ b/packages/font-pack-registry/src/types.ts @@ -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 { + web: InstalledFontPackAsset & { format: "woff2" }; + docx: InstalledFontPackAsset & { format: "truetype" }; +} + +export interface InstalledFontPack + extends Omit { + 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; +} diff --git a/packages/font-pack-registry/tests/fixtures.ts b/packages/font-pack-registry/tests/fixtures.ts new file mode 100644 index 0000000..957bef1 --- /dev/null +++ b/packages/font-pack-registry/tests/fixtures.ts @@ -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; +} diff --git a/packages/font-pack-registry/tests/registry.test.ts b/packages/font-pack-registry/tests/registry.test.ts new file mode 100644 index 0000000..d3eeea0 --- /dev/null +++ b/packages/font-pack-registry/tests/registry.test.ts @@ -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"); + }); +}); diff --git a/packages/font-pack-registry/tests/resolver.test.ts b/packages/font-pack-registry/tests/resolver.test.ts new file mode 100644 index 0000000..53eee44 --- /dev/null +++ b/packages/font-pack-registry/tests/resolver.test.ts @@ -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"); + }); +}); diff --git a/packages/font-pack-registry/tests/schema.test.ts b/packages/font-pack-registry/tests/schema.test.ts new file mode 100644 index 0000000..572a418 --- /dev/null +++ b/packages/font-pack-registry/tests/schema.test.ts @@ -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); + }); +}); diff --git a/packages/font-pack-registry/tsconfig.json b/packages/font-pack-registry/tsconfig.json new file mode 100644 index 0000000..9960e15 --- /dev/null +++ b/packages/font-pack-registry/tsconfig.json @@ -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" + ] +}