UNPKG

maplibre-gl

Version:

BSD licensed community fork of mapbox-gl, a WebGL interactive maps library

281 lines (248 loc) • 11.3 kB
import {getArrayBuffer} from '../util/ajax.ts'; import {ResourceType} from '../util/request_manager.ts'; import {ensureError, warnOnce} from '../util/util.ts'; import type {FontFacesSpecification, MLFontFace} from '@maplibre/maplibre-gl-style-spec'; import type {RequestManager} from '../util/request_manager.ts'; /** * An inclusive range of Unicode codepoints, parsed out of a `unicode-range` entry. */ export type UnicodeRange = { start: number; end: number; }; const MAX_CODE_POINT = 0x10FFFF; /** * What a font file covers when the style does not narrow it down with `unicode-range`. */ const DEFAULT_UNICODE_RANGE: UnicodeRange = {start: 0, end: MAX_CODE_POINT}; /** OpenType feature to enable, or `normal` for default settings. */ export type FontFeature = 'normal' | 'vert'; /** * A font file declared by the style, along with the bookkeeping needed to draw with it. */ type DeclaredFontFace = { /** * The font file's URL, as written in the style. */ url: string; /** * The codepoints the style lets this file cover. */ ranges: UnicodeRange[]; /** * The CSS family name the file is registered under. It is generated rather than taken from the * style so that a style cannot restyle the surrounding page by declaring a font face named * after one the page uses, and so that each file can be selected on its own. */ family: string; /** Cached load results by feature: the CSS family, or `null` if unavailable. */ loads: Partial<Record<FontFeature, Promise<string | null>>>; /** Font download temporarily shared by overlapping face loads. */ data?: Promise<ArrayBuffer>; }; /** * Makes the family names unique across every map on the page, since `document.fonts` is shared. */ let nextFamilyId = 0; /** * Parses one `unicode-range` entry, in the CSS grammar the style specification borrows: `U+A5`, * `U+0-10FFFF` or `U+4??`. * @param value - a single `unicode-range` entry * @returns the codepoints it covers, or `null` if it is not a range this can make sense of */ function parseUnicodeRange(value: string): UnicodeRange | null { const wildcard = /^u\+([0-9a-f]*)(\?+)$/i.exec(value); if (wildcard) { const [, prefix, questionMarks] = wildcard; if (prefix.length + questionMarks.length > 6) return null; return clampRange( parseInt(`${prefix}${'0'.repeat(questionMarks.length)}`, 16), parseInt(`${prefix}${'f'.repeat(questionMarks.length)}`, 16) ); } const explicit = /^u\+([0-9a-f]{1,6})(?:-([0-9a-f]{1,6}))?$/i.exec(value); if (!explicit) return null; const start = parseInt(explicit[1], 16); return clampRange(start, explicit[2] === undefined ? start : parseInt(explicit[2], 16)); } function clampRange(start: number, end: number): UnicodeRange | null { if (start > end || start > MAX_CODE_POINT) return null; return {start, end: Math.min(end, MAX_CODE_POINT)}; } /** * Whether the style lets this file cover the given codepoint. */ function covers(face: DeclaredFontFace, codePoint: number): boolean { return face.ranges.some(({start, end}) => codePoint >= start && codePoint <= end); } /** * Keeps the font files a style declares in its * [`font-faces`](https://maplibre.org/maplibre-style-spec/root/#font-faces) property, and hands the * {@link GlyphManager} the CSS family to draw a given codepoint with. * * The files go to the browser's CSS Font Loading API, so any format it can render text with works. * Each is registered under a family name of our own making, so that a style cannot restyle the * surrounding page and a codepoint can be pinned to one file rather than to whatever font matching * picks. * * Rasterizing happens a grapheme cluster at a time, so a letter reaches the browser's text engine * with its marks. Shaping across cluster boundaries still needs the RTL text plugin. */ export class FontFaceManager { requestManager: RequestManager; /** * The declared files by the font name used in `text-font`, in the order the style listed them. */ _faces: Record<string, DeclaredFontFace[]>; /** * Everything handed to `document.fonts`, so that it can all be handed back on destroy. */ _registered: Set<FontFace>; constructor(requestManager: RequestManager) { this.requestManager = requestManager; this._faces = {}; this._registered = new Set(); } /** * Replaces the declared font faces. Nothing is downloaded here: each file waits until a * codepoint it covers is actually drawn. */ setFontFaces(fontFaces?: FontFacesSpecification | null): void { this._unregisterAll(); this._faces = {}; for (const [fontName, declaration] of Object.entries(fontFaces ?? {})) { const declarations = Array.isArray(declaration) ? declaration : [declaration]; this._faces[fontName] = declarations .map(entry => this._declareFontFace(fontName, entry)) .filter(face => face !== null); } } /** * Whether the style declared any font face at all, so that the common case of a style without * `font-faces` costs nothing. */ hasFontFaces(): boolean { return Object.keys(this._faces).length > 0; } /** * Finds the font file to draw a codepoint with: each name of the `text-font` stack in turn, and * within a name each declared file, until one covers it. A file that fails to load is skipped, * the specification asking for unsupported fonts to be ignored. * * Requests with a feature enabled use the normal face's file and return `null` if loading fails. * * @param fontStack - comma-separated font names, in fallback order * @param codePoint - codepoint to match against the declared Unicode ranges * @param feature - OpenType feature to enable, or `normal` for default settings * @returns the loaded CSS family, or `null` if the requested face is unavailable */ async getFontFamily(fontStack: string, codePoint: number, feature: FontFeature): Promise<string | null> { for (const fontName of fontStack.split(',')) { for (const face of this._faces[fontName.trim()] ?? []) { if (!covers(face, codePoint)) continue; face.loads.normal ??= this._loadFontFace(face, 'normal'); const data = face.data; const family = await face.loads.normal; if (!family) continue; if (feature === 'normal') return family; face.loads[feature] ??= this._loadFontFace(face, feature, data); return face.loads[feature]; } } return null; } /** * Turns one declaration into a face to draw with, or `null` if there is nothing usable in it. * * A bare URL is the same as a face that names no `unicode-range`: it covers every codepoint. */ _declareFontFace(fontName: string, declaration: string | MLFontFace): DeclaredFontFace | null { const face: MLFontFace = typeof declaration === 'string' ? {url: declaration} : declaration; if (typeof face?.url !== 'string') { warnOnce(`Ignoring the font face declared for "${fontName}": it has no URL.`); return null; } const family = `maplibre-gl-font-face-${nextFamilyId++}`; const unicodeRange = face['unicode-range']; if (!unicodeRange?.length) { return {url: face.url, ranges: [DEFAULT_UNICODE_RANGE], family, loads: {}}; } const ranges: UnicodeRange[] = []; for (const entry of unicodeRange) { const range = parseUnicodeRange(entry); if (!range) { warnOnce(`Ignoring the unicode range "${entry}" of the font face at ${face.url}: it is not a valid range.`); continue; } ranges.push(range); } if (!ranges.length) return null; return {url: face.url, ranges, family, loads: {}}; } /** * Loads and registers a declared font with the requested OpenType feature. * * @param face - font file declaration and CSS family to register * @param feature - OpenType feature to enable, or `normal` for default settings * @param data - a download retained by a concurrent request while the normal face loads * @returns the registered CSS family, or `null` on failure, ignored feature settings or disposal */ async _loadFontFace(face: DeclaredFontFace, feature: FontFeature, data?: Promise<ArrayBuffer>): Promise<string | null> { const description = feature === 'normal' ? 'font face' : `font face with "${feature}" enabled`; if (typeof FontFace === 'undefined' || typeof document === 'undefined' || !document.fonts) { warnOnce(`Ignoring the ${description} at ${face.url}: this environment has no CSS Font Loading API.`); return null; } let fontFace: FontFace; try { if (!data) { face.data ??= this._downloadFontFile(face.url); data = face.data; } const family = feature === 'normal' ? face.family : `${face.family}-${feature}`; fontFace = new FontFace(family, await data, {featureSettings: feature === 'normal' ? 'normal' : `"${feature}" 1`}); if (!Object.values(this._faces).some(faces => faces.includes(face))) return null; if (feature !== 'normal' && (!fontFace.featureSettings || fontFace.featureSettings === 'normal')) return null; document.fonts.add(fontFace); this._registered.add(fontFace); await fontFace.load(); return this._registered.has(fontFace) ? family : null; } catch (e) { if (fontFace) this._unregister(fontFace); warnOnce(`Ignoring the ${description} at ${face.url}: ${ensureError(e).message}`); return null; } finally { if (face.data === data) delete face.data; } } /** * Downloads a font file, giving the map's `transformRequest` a chance to rewrite the request the * same way it gets one for a glyph range. */ async _downloadFontFile(url: string): Promise<ArrayBuffer> { const request = await this.requestManager.transformRequest(url, ResourceType.Glyphs); const response = await getArrayBuffer(request, new AbortController()); if (!response?.data) { throw new Error(`the response was empty for the font file at ${url}`); } return response.data; } _unregister(fontFace: FontFace): void { document.fonts?.delete(fontFace); this._registered.delete(fontFace); } _unregisterAll(): void { for (const faces of Object.values(this._faces)) { for (const face of faces) delete face.data; } for (const fontFace of this._registered) { document.fonts?.delete(fontFace); } this._registered.clear(); } destroy(): void { this._unregisterAll(); this._faces = {}; } }