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) ); }