Files
MorphDoc/packages/font-pack-registry/src/schema.ts
T

261 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
);
}