web-font/backend/font_util/font_meta.ts
崮生(子虚) fa3fa15bf7 feat: font-meta 并发限制 + metaVersion 磁盘缓存失效 + 清理废弃参数
- font-meta 首次计算复用 withMemoryGate 并发闸门(与子集化共享内存预算)
- FontMeta 加 metaVersion 版本指纹,算法变更后 .meta.json 自动失效
- 删除 withMemoryGate/initMemoryGate 废弃的 _softLimitMB 参数
- 清理旧 .meta.json 缓存文件
2026-07-31 00:10:25 +08:00

485 lines
17 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.

/**
* 字体元数据提取 —— 从字体字节解析 cmap提取完整 codepoint 集合,
* 并对照标准字符集计算覆盖率。
*
* 直接解析 cmap 表的 format4BMP和 format12含补充平面的 segment/group
* 遍历所有区间收集完整 codepoint 集合。不依赖 fonteditor-core 的 Font.create
* 避免解析 glyf 轮廓(对大字体可省数十毫秒)。
*/
/** ttf/otf 表目录条目 */
interface TableEntry {
offset: number;
length: number;
}
/** 解析表目录,返回指定 tag 的 (offset, length) */
function readTableEntry(dv: DataView, tag: string): TableEntry | null {
if (dv.byteLength < 12) return null;
const numTables = dv.getUint16(4, false);
if (numTables <= 0 || numTables > 100) return null;
let off = 12;
for (let i = 0; i < numTables; i++) {
const recOff = off + i * 16;
if (recOff + 16 > dv.byteLength) return null;
const t0 = dv.getUint8(recOff);
const t1 = dv.getUint8(recOff + 1);
const t2 = dv.getUint8(recOff + 2);
const t3 = dv.getUint8(recOff + 3);
if (t0 === tag.charCodeAt(0) && t1 === tag.charCodeAt(1) && t2 === tag.charCodeAt(2) && t3 === tag.charCodeAt(3)) {
return { offset: dv.getUint32(recOff + 8, false), length: dv.getUint32(recOff + 12, false) };
}
}
return null;
}
/**
* 从 cmap 的 format4 subtable 遍历所有 segment收集每个 segment 中 gid≠0 的 codepoint。
* format4 表布局见 gsub-probe.ts 的 lookupFormat4 注释。
*/
/**
* 构建 glyph 验证器:对 TrueType 字体,检查 gid 在 loca 表中对应 glyf 长度 > 0。
* CFF 字体(无 glyf/loca则返回恒真函数。
*/
function createGlyphValidator(dv: DataView): (gid: number) => boolean {
const glyfEntry = readTableEntry(dv, "glyf");
const locaEntry = readTableEntry(dv, "loca");
const headEntry = readTableEntry(dv, "head");
if (!glyfEntry || !locaEntry || !headEntry) {
/** CFF 字体cmap 有映射即有效 */
return () => true;
}
/** indexToLocFormat0=short(uint16偏移×2), 1=long(uint32偏移) */
const locaFormat = dv.getUint16(headEntry.offset + 50, false);
const locaBase = locaEntry.offset;
return (gid: number): boolean => {
let start: number;
let end: number;
if (locaFormat === 0) {
start = dv.getUint16(locaBase + gid * 2, false) * 2;
end = dv.getUint16(locaBase + (gid + 1) * 2, false) * 2;
} else {
start = dv.getUint32(locaBase + gid * 4, false);
end = dv.getUint32(locaBase + (gid + 1) * 4, false);
}
return end - start > 0;
};
}
function collectFormat4(dv: DataView, subOff: number, cps: Set<number>, hasGlyph: (gid: number) => boolean): void {
if (subOff + 14 > dv.byteLength) return;
const segCountX2 = dv.getUint16(subOff + 6, false);
const segCount = segCountX2 / 2;
const endCodeBase = subOff + 14;
const startCodeBase = endCodeBase + segCount * 2 + 2;
const idDeltaBase = startCodeBase + segCount * 2;
const idRangeOffsetBase = idDeltaBase + segCount * 2;
for (let i = 0; i < segCount; i++) {
const start = dv.getUint16(startCodeBase + i * 2, false);
const end = dv.getUint16(endCodeBase + i * 2, false);
/** 0xFFFF 的 segment 是 format4 必有的哨兵,跳过 */
if (start === 0xFFFF) continue;
const idDelta = dv.getInt16(idDeltaBase + i * 2, false);
const idRangeOffset = dv.getUint16(idRangeOffsetBase + i * 2, false);
if (idRangeOffset === 0) {
/** 线性映射gid = (cp + idDelta) & 0xFFFFgid=0 表示缺失 */
for (let cp = start; cp <= end; cp++) {
const gid = (cp + idDelta) & 0xFFFF;
if (gid !== 0 && hasGlyph(gid)) cps.add(cp);
}
} else {
/** 逐个查 glyphIdArray */
for (let cp = start; cp <= end; cp++) {
const glyphOff = idRangeOffsetBase + i * 2 + idRangeOffset + (cp - start) * 2;
if (glyphOff + 2 > dv.byteLength) break;
const glyphId = dv.getUint16(glyphOff, false);
const gid = (glyphId + idDelta) & 0xFFFF;
if (glyphId !== 0 && gid !== 0 && hasGlyph(gid)) cps.add(cp);
}
}
}
}
/**
* 从 cmap 的 format12 subtable 遍历所有 group收集 gid 在范围内的 codepoint。
* format12 表布局见 gsub-probe.ts 的 lookupFormat12 注释。
*/
function collectFormat12(dv: DataView, subOff: number, cps: Set<number>, hasGlyph: (gid: number) => boolean): void {
if (subOff + 16 > dv.byteLength) return;
const nGroups = dv.getUint32(subOff + 12, false);
const groupsBase = subOff + 16;
for (let i = 0; i < nGroups; i++) {
const gOff = groupsBase + i * 12;
if (gOff + 12 > dv.byteLength) break;
const gStart = dv.getUint32(gOff, false);
const gEnd = dv.getUint32(gOff + 4, false);
const gGid = dv.getUint32(gOff + 8, false);
/** 逐个验证 gid 是否有真实轮廓(空轮廓 glyph 会被 loca 表过滤掉) */
for (let cp = gStart; cp <= gEnd; cp++) {
const gid = gGid + (cp - gStart);
if (gid !== 0 && hasGlyph(gid)) cps.add(cp);
}
}
}
/**
* 从字体字节提取所有支持的 Unicode codepoint 集合。
* 优先解析 format12覆盖更广再补 format4BMP 兜底)。
*/
export function extractCodePoints(fontBuffer: ArrayBuffer | Uint8Array): Set<number> {
const buf = fontBuffer instanceof Uint8Array ? fontBuffer.buffer : fontBuffer;
const dv = new DataView(buf);
const cmapEntry = readTableEntry(dv, "cmap");
const cps = new Set<number>();
if (cmapEntry === null) return cps;
/** 选择 format4 和 format12 的 subtable 偏移 */
const numberSubtables = dv.getUint16(cmapEntry.offset + 2, false);
let fmt4Off = -1;
let fmt12Off = -1;
let dirOff = cmapEntry.offset + 4;
for (let i = 0; i < numberSubtables; i++) {
if (dirOff + 8 > dv.byteLength) break;
const platformID = dv.getUint16(dirOff, false);
const encodingID = dv.getUint16(dirOff + 2, false);
const subRelOff = dv.getUint32(dirOff + 4, false);
const subOff = cmapEntry.offset + subRelOff;
if (subOff + 2 <= dv.byteLength) {
const format = dv.getUint16(subOff, false);
if (format === 12 && platformID === 3 && encodingID === 10 && fmt12Off < 0) {
fmt12Off = subOff;
} else if (format === 4 && platformID === 3 && encodingID === 1 && fmt4Off < 0) {
fmt4Off = subOff;
}
}
dirOff += 8;
}
/** 创建 glyph 轮廓验证器TrueType 检查 locaCFF 恒真) */
const hasGlyph = createGlyphValidator(dv);
/** format12 优先含补充平面format4 补充 BMP */
if (fmt12Off >= 0) collectFormat12(dv, fmt12Off, cps, hasGlyph);
if (fmt4Off >= 0) collectFormat4(dv, fmt4Off, cps, hasGlyph);
return cps;
}
// ────────────────────── 标准字符集定义 ─────────────────────-
/** 字符集覆盖率结果 */
export interface CharsetCoverage {
/** 字符集标识 */
key: string;
/** 字符集名称(中文) */
name: string;
/** 该字符集的总字符数 */
total: number;
/** 字体支持的字符数 */
covered: number;
/** 覆盖率百分比 (0~100保留一位小数) */
percent: number;
}
/**
* 计算给定 Unicode 区间数组中,字体覆盖了多少 codepoint。
* 区间数组格式:[[start, end], ...],每个区间连续。
*/
function countCoverage(cps: Set<number>, ranges: ReadonlyArray<readonly [number, number]>): { total: number; covered: number } {
let total = 0;
let covered = 0;
for (const [start, end] of ranges) {
for (let cp = start; cp <= end; cp++) {
total++;
if (cps.has(cp)) covered++;
}
}
return { total, covered };
}
/**
* 标准字符集列表 —— 全部用 Unicode 区间表示,基于 Unicode 官方区块定义。
* 覆盖率是近似值(区间内含少量非目标字符),但对字体选型参考足够精确。
*
* - ascii: ASCII 可打印字符含字母、数字、常见标点U+0020~U+007E
* - commonHanzi: 常用汉字U+4E00~U+5535约 3500 字),覆盖日常中文 99.8%
* - cjkBasic: CJK 统一汉字基本区U+4E00~U+9FFF20992 码位),含简繁体
* - cjkExtA: CJK 扩展A 区U+3400~U+4DBF罕见字/古字
* - punctuation: CJK 标点符号U+3000~U+303F
* - fullwidth: 全角字符U+FF00~U+FFEF
* - cyrillic: 西里尔字母/俄文U+0400~U+04FF
*/
const CHARSETS: Array<{ key: string; name: string; ranges: ReadonlyArray<readonly [number, number]> }> = [
{ key: "ascii", name: "英文字母数字", ranges: [[0x20, 0x7e]] },
{ key: "commonHanzi", name: "常用汉字约3500", ranges: [[0x4e00, 0x5bad]] },
{ key: "cjkBasic", name: "CJK 基本汉字20992", ranges: [[0x4e00, 0x9fff]] },
{ key: "cjkExtA", name: "CJK 扩展A罕用字", ranges: [[0x3400, 0x4dbf]] },
{ key: "punctuation", name: "中文标点", ranges: [[0x3000, 0x303f]] },
{ key: "fullwidth", name: "全角字符", ranges: [[0xff00, 0xffef]] },
{ key: "cyrillic", name: "西里尔字母/俄文", ranges: [[0x400, 0x4ff]] },
];
/**
* 计算字体对各类标准字符集的覆盖率。
*/
export function calcCoverage(cps: Set<number>): CharsetCoverage[] {
return CHARSETS.map(({ key, name, ranges }) => {
const { total, covered } = countCoverage(cps, ranges);
return {
key,
name,
total,
covered,
percent: total > 0 ? Math.round((covered / total) * 1000) / 10 : 0,
};
});
}
/**
* 将 codepoint 集合编码为紧凑的区间数组(用于传输/存储)。
* 每个区间 [start, end] 表示连续的 codepoint 范围。
*/
export function codePointsToRanges(cps: Set<number>): Array<[number, number]> {
const sorted = [...cps].sort((a, b) => a - b);
const ranges: Array<[number, number]> = [];
let i = 0;
while (i < sorted.length) {
const start = sorted[i];
let end = start;
while (i + 1 < sorted.length && sorted[i + 1] === end + 1) {
end = sorted[++i];
}
ranges.push([start, end]);
i++;
}
return ranges;
}
/** OpenType name 表中的标准名称 ID */
const NAME_ID = {
COPYRIGHT: 0,
FAMILY: 1,
SUBFAMILY: 2,
UNIQUE_ID: 3,
FULL_NAME: 4,
VERSION: 5,
POSTSCRIPT: 6,
TRADEMARK: 7,
MANUFACTURER: 8,
DESIGNER: 9,
DESCRIPTION: 10,
VENDOR_URL: 11,
DESIGNER_URL: 12,
LICENSE: 13,
LICENSE_URL: 14,
} as const;
/** 需要提取的 nameID 列表 */
const EXTRACT_NAME_IDS = [
NAME_ID.COPYRIGHT,
NAME_ID.FAMILY,
NAME_ID.SUBFAMILY,
NAME_ID.FULL_NAME,
NAME_ID.VERSION,
NAME_ID.POSTSCRIPT,
NAME_ID.TRADEMARK,
NAME_ID.MANUFACTURER,
NAME_ID.DESIGNER,
NAME_ID.DESCRIPTION,
NAME_ID.VENDOR_URL,
NAME_ID.DESIGNER_URL,
NAME_ID.LICENSE,
NAME_ID.LICENSE_URL,
] as const;
/** 字体基本信息(从 name 表提取) */
export interface FontInfo {
/** 版权声明 */
copyright?: string;
/** 字体族名 */
family?: string;
/** 字体子族名(如 Regular、Bold */
subfamily?: string;
/** 唯一标识 */
uniqueId?: string;
/** 完整名称 */
fullName?: string;
/** 版本号 */
version?: string;
/** PostScript 名称 */
postScript?: string;
/** 商标声明 */
trademark?: string;
/** 制造商/出版商 */
manufacturer?: string;
/** 设计师 */
designer?: string;
/** 描述 */
description?: string;
/** 厂商 URL */
vendorUrl?: string;
/** 设计师 URL */
designerUrl?: string;
/** 许可声明 */
license?: string;
/** 许可 URL */
licenseUrl?: string;
}
/**
* 解码 name record 的字符串。
* platformID=3 (Windows) 使用 UTF-16BEplatformID=1 (Mac) 使用 Latin-1/ASCII。
*/
function decodeNameString(dv: DataView, offset: number, length: number, platformID: number): string {
if (offset + length > dv.byteLength) return "";
if (platformID === 3) {
/** UTF-16BE */
const codes: number[] = [];
for (let i = 0; i < length; i += 2) {
codes.push(dv.getUint16(offset + i, false));
}
return String.fromCodePoint(...codes);
}
/** Mac Roman / ASCII 近似 */
const bytes = new Uint8Array(dv.buffer, offset, length);
return new TextDecoder("utf-8").decode(bytes);
}
/**
* 从 name 表提取字体基本信息。
* 优先取 platformID=3 (Windows) 的英文记录,其次 platformID=0/1。
*/
export function extractFontInfo(fontBuffer: ArrayBuffer | Uint8Array): FontInfo {
const buf = fontBuffer instanceof Uint8Array ? fontBuffer.buffer : fontBuffer;
const dv = new DataView(buf);
const nameEntry = readTableEntry(dv, "name");
if (nameEntry === null) return {};
const base = nameEntry.offset;
if (base + 6 > dv.byteLength) return {};
const format = dv.getUint16(base, false);
const count = dv.getUint16(base + 2, false);
/** stringOffset相对 name 表起始的偏移,指向字符串存储区 */
const stringOffset = dv.getUint16(base + 4, false);
const storageBase = base + stringOffset;
/** 收集每个 nameID 的最佳字符串优先级Windows(3) > Mac(1) */
const collected = new Map<number, { text: string; priority: number }>();
const recordBase = base + 6;
for (let i = 0; i < count; i++) {
const recOff = recordBase + i * 12;
if (recOff + 12 > dv.byteLength) break;
const platformID = dv.getUint16(recOff, false);
const encodingID = dv.getUint16(recOff + 2, false);
const languageID = dv.getUint16(recOff + 4, false);
const nameID = dv.getUint16(recOff + 6, false);
const length = dv.getUint16(recOff + 8, false);
const offset = dv.getUint16(recOff + 10, false);
if (!EXTRACT_NAME_IDS.includes(nameID as (typeof EXTRACT_NAME_IDS)[number])) continue;
/** 优先级Windows 英文(3,1,0x409) > Windows 中文(3,1,*) > Mac(1,*) > 其他 */
let priority = 0;
if (platformID === 3 && languageID === 0x0409) priority = 4;
else if (platformID === 3 && encodingID === 1) priority = 3;
else if (platformID === 3) priority = 2;
else if (platformID === 1) priority = 1;
const existing = collected.get(nameID);
if (existing && existing.priority >= priority) continue;
const text = decodeNameString(dv, storageBase + offset, length, platformID);
if (text) collected.set(nameID, { text, priority });
}
/** format 1 有 lang-tag record跳过 */
void format;
const info: FontInfo = {};
const get = (id: number): string | undefined => collected.get(id)?.text;
info.copyright = get(NAME_ID.COPYRIGHT);
info.family = get(NAME_ID.FAMILY);
info.subfamily = get(NAME_ID.SUBFAMILY);
info.uniqueId = get(NAME_ID.UNIQUE_ID);
info.fullName = get(NAME_ID.FULL_NAME);
info.version = get(NAME_ID.VERSION);
info.postScript = get(NAME_ID.POSTSCRIPT);
info.trademark = get(NAME_ID.TRADEMARK);
info.manufacturer = get(NAME_ID.MANUFACTURER);
info.designer = get(NAME_ID.DESIGNER);
info.description = get(NAME_ID.DESCRIPTION);
info.vendorUrl = get(NAME_ID.VENDOR_URL);
info.designerUrl = get(NAME_ID.DESIGNER_URL);
info.license = get(NAME_ID.LICENSE);
info.licenseUrl = get(NAME_ID.LICENSE_URL);
return info;
}
/** 人工配置项(来自 font/font-config.json由用户维护 */
export interface FontUserConfig {
/** 显示名称(优先于文件名) */
displayName?: string;
/** 描述/简介 */
description?: string;
/** 标签列表 */
tags?: string[];
/** 开源仓库地址(如 GitHub URL */
homepage?: string;
/** 默认预览文字 */
previewText?: string;
/** 详情页正文标题 */
bodyTitle?: string;
/** 详情页正文段落 */
bodyText?: string;
/** 详情页字符预览行 */
charsetPreview?: string;
}
/** 字体元数据结果 */
export interface FontMeta {
/** 字体支持的 codepoint 总数 */
totalCodePoints: number;
/** 各字符集覆盖率 */
coverage: CharsetCoverage[];
/**
* 字体支持的所有 codepoint 区间(紧凑表示)。
* 前端可据此判断任意字符是否被支持,无需请求完整字符列表。
* 例如 [[0x20, 0x7e], [0x4e00, 0x9fff]] 表示支持 ASCII 和全部基本汉字。
*/
ranges: Array<[number, number]>;
/** 字体基本信息(版权、作者等,来自 name 表) */
info: FontInfo;
/** 人工配置(来自 font-config.json由路由层合并 */
config?: FontUserConfig;
}
/**
* 元数据版本指纹
*
* extractCodePoints / calcCoverage 的算法变更后手动 bump
* 让路由层的 .meta.json 磁盘缓存自动失效(旧文件的 metaVersion 不匹配即重算)。
*/
export const META_VERSION = 3;
/**
* 提取字体元数据codepoint 总数、覆盖率、支持的区间、字体基本信息。
* 注意config 字段由路由层从 font-config.json 填充,此处不包含。
*/
export function extractFontMeta(fontBuffer: ArrayBuffer | Uint8Array): FontMeta {
const cps = extractCodePoints(fontBuffer);
return {
metaVersion: META_VERSION,
totalCodePoints: cps.size,
coverage: calcCoverage(cps),
ranges: codePointsToRanges(cps),
info: extractFontInfo(fontBuffer),
};
}