UNPKG

maplibre-gl

Version:

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

434 lines (380 loc) 17.7 kB
import {FontFaceManager} from './font_face_manager.ts'; import TinySDF, {type TinySDFOptions} from '@mapbox/tiny-sdf'; import {codePointUsesLocalIdeographFontFamily} from '../util/unicode_properties.g.ts'; import {isCluster} from '../util/graphemes.ts'; import {AlphaImage} from '../util/image.ts'; import {ensureError, warnOnce} from '../util/util.ts'; import {getArrayBuffer} from '../util/ajax.ts'; import {ResourceType} from '../util/request_manager.ts'; import {parseGlyphPbf} from '../style/parse_glyph_pbf.ts'; import type {StyleGlyph} from '../style/style_glyph.ts'; import type {RequestManager} from '../util/request_manager.ts'; import type {GetGlyphsResponse} from '../util/actor_messages.ts'; import type {FontFacesSpecification} from '@maplibre/maplibre-gl-style-spec'; import {v8} from '@maplibre/maplibre-gl-style-spec'; type Entry = { /** * The glyphs drawn or downloaded so far, keyed by grapheme cluster. `null` means the glyph was * asked for and is not available: either the range came back without it, or it is a cluster * that no font file covers. */ glyphs: Record<string, StyleGlyph | null>; requests: Record<number, Promise<{[_: number]: StyleGlyph | null}>>; ranges: Record<number, boolean | null>; tinySDF?: Promise<Rasterizer>; ideographTinySDF?: Promise<Rasterizer>; /** * One TinySDF per `font-faces` file this stack draws with, keyed by the file's CSS family. */ fontFaceTinySDFs?: Record<string, Promise<Rasterizer>>; /** * One TinySDF per font selection used to draw whole grapheme clusters, which need a wider canvas * to fit in than a single codepoint does. */ clusterTinySDFs?: Record<string, Promise<Rasterizer>>; }; /** * The style specification hard-codes some last resort fonts as a default fontstack. */ const defaultStack = v8.layout_symbol['text-font'].default.join(','); /** * The CSS generic font family closest to `defaultStack`. */ const defaultGenericFontFamily = 'sans-serif'; /** * Scale factor for client-generated glyphs. * * Client-generated glyphs are rendered at 2× because CJK glyphs are more detailed than others. */ const textureScale = 2; /** * The rasterizer, as it really is. TinySDF carries the `buffer` it was built with, which its type * declaration leaves out and which the canvas has to be widened through. */ export type Rasterizer = TinySDF & {buffer: number}; /** * Builds the thing that rasterizes a glyph. Taken as a dependency so that a test can stand in for * the canvas it would otherwise need to draw on. * * @param options - what to draw with, whose `buffer` sizes the canvas * @param padding - the room to leave around each glyph in the bitmap, which is not that buffer */ export type CreateRasterizer = (options: TinySDFOptions, padding: number) => Rasterizer; const defaultCreateRasterizer: CreateRasterizer = (options, padding) => { const tinySDF = new TinySDF(options) as Rasterizer; tinySDF.buffer = padding; return tinySDF; }; /** * How wide a grapheme cluster may be drawn, in multiples of the font size. * * TinySDF sizes its canvas for a single character and cuts off the rest, but a cluster is a whole * syllable: Burmese `လား` is nearly twice the font size. Three ems fits the ones that occur. */ const clusterEmsWide = 3; export class GlyphManager { requestManager: RequestManager; localIdeographFontFamily: string | false; entries: Record<string, Entry>; url: string; lang?: string; fontFaceManager: FontFaceManager; createRasterizer: CreateRasterizer; constructor( requestManager: RequestManager, localIdeographFontFamily?: string | false, lang?: string, createRasterizer: CreateRasterizer = defaultCreateRasterizer ) { this.requestManager = requestManager; this.localIdeographFontFamily = localIdeographFontFamily; this.entries = {}; this.lang = lang; this.fontFaceManager = new FontFaceManager(requestManager); this.createRasterizer = createRasterizer; } setURL(url?: string | null): void { this.url = url; } /** * Replaces the font files the style declares in its `font-faces` property, dropping every glyph * drawn with the previous ones. */ setFontFaces(fontFaces?: FontFacesSpecification | null): void { this.fontFaceManager.setFontFaces(fontFaces); this.entries = {}; } async getGlyphs(glyphs: Record<string, string[]>): Promise<GetGlyphsResponse> { const glyphsPromises: Array<Promise<{stack: string; id: string; glyph: StyleGlyph}>> = []; for (const stack in glyphs) { for (const id of glyphs[stack]) { glyphsPromises.push(this._getAndCacheGlyphsPromise(stack, id)); } } const updatedGlyphs = await Promise.all(glyphsPromises); const result: GetGlyphsResponse = {}; for (const {stack, id, glyph} of updatedGlyphs) { result[stack] ||= {}; // Clone the glyph so that our own copy of its ArrayBuffer doesn't get transferred. result[stack][id] = glyph && { id: glyph.id, bitmap: glyph.bitmap.clone(), metrics: glyph.metrics }; } return result; } /** * Gets one glyph, asked for by grapheme cluster so that a letter and its marks are drawn as the * one shape they are written as. * * A cluster is drawn from a font rather than fetched, because a glyphs URL serves codepoints and * has no way to serve the one shape they are written as. A file the style pinned with * `font-faces` draws it where one covers it, and the local fonts otherwise. For a single * codepoint a declared file still wins over the glyphs URL and the local fallbacks. */ async _getAndCacheGlyphsPromise(stack: string, id: string): Promise<{stack: string; id: string; glyph: StyleGlyph}> { // Create an entry for this fontstack if it doesn’t already exist. this.entries[stack] ??= {glyphs: {}, requests: {}, ranges: {}}; const entry = this.entries[stack]; // Try to get the glyph from the cache of client-side glyphs. let glyph = entry.glyphs[id]; if (glyph !== undefined) { return {stack, id, glyph}; } const codePoint = id.codePointAt(0); const fontFaceFamily = this.fontFaceManager.hasFontFaces() ? await this.fontFaceManager.getFontFamily(stack, codePoint) : null; if (fontFaceFamily) { glyph = entry.glyphs[id] = await this._drawGlyph(entry, stack, id, fontFaceFamily); return {stack, id, glyph}; } // If the style hasn’t opted into server-side fonts, this codepoint is CJK, or this is a cluster // that a codepoint-keyed glyphs URL cannot serve, draw the glyph locally and cache it. if (!this.url || isCluster(id) || this._charUsesLocalIdeographFontFamily(codePoint)) { glyph = entry.glyphs[id] = await this._drawGlyph(entry, stack, id); return {stack, id, glyph}; } return await this._downloadAndCacheRangePromise(stack, id); } /** * Gets a glyph from the server-side cache, downloading the PBF range it falls in if need be. * * Only reached for a single codepoint. What comes back is keyed by codepoint, as the file is, * and is cached by cluster -- which for one codepoint is the character itself. */ async _downloadAndCacheRangePromise(stack: string, id: string): Promise<{stack: string; id: string; glyph: StyleGlyph}> { const codePoint = id.codePointAt(0); const entry = this.entries[stack]; const range = Math.floor(codePoint / 256); if (entry.ranges[range]) { return {stack, id, glyph: null}; } // Start downloading this range unless we’re currently downloading it. entry.requests[range] ||= this._loadGlyphRange(stack, range); try { // Get the response and cache the glyphs from it. const response = await entry.requests[range]; for (const responseId in response) { entry.glyphs[String.fromCodePoint(+responseId)] = response[+responseId]; } entry.ranges[range] = true; return {stack, id, glyph: response[codePoint] || null}; } catch (e) { // Fall back to drawing the glyph locally and caching it. const glyph = entry.glyphs[id] = await this._drawGlyph(entry, stack, id); this._warnOnMissingGlyphRange(glyph, range, codePoint, ensureError(e)); return {stack, id, glyph}; } } /** * Downloads one range of 256 codepoints from the glyphs URL and parses the glyphs out of it. */ async _loadGlyphRange(fontstack: string, range: number): Promise<Record<number, StyleGlyph | null>> { const begin = range * 256; const end = begin + 255; const request = await this.requestManager.transformRequest( this.url.replace('{fontstack}', fontstack).replace('{range}', `${begin}-${end}`), ResourceType.Glyphs ); const response = await getArrayBuffer(request, new AbortController()); if (!response?.data) { throw new Error(`Could not load glyph range. range: ${range}, ${begin}-${end}`); } const glyphs = {}; for (const glyph of parseGlyphPbf(response.data)) { glyphs[glyph.id] = glyph; } return glyphs; } _warnOnMissingGlyphRange(glyph: StyleGlyph, range: number, id: number, err: Error): void { const begin = range * 256; const end = begin + 255; const codePoint = id.toString(16).padStart(4, '0').toUpperCase(); warnOnce(`Unable to load glyph range ${range}, ${begin}-${end}. Rendering codepoint U+${codePoint} locally instead. ${err}`); } /** * Returns whether the given codepoint should be rendered locally. */ _charUsesLocalIdeographFontFamily(id: number): boolean { return !!this.localIdeographFontFamily && codePointUsesLocalIdeographFontFamily(id); } /** * Draws a glyph offscreen using TinySDF, created lazily. The whole cluster goes to TinySDF, which * is what lets the browser's text engine place a letter's marks on it. * * @param fontFaceFamily - the CSS family of the `font-faces` file covering this codepoint, if any */ async _drawGlyph(entry: Entry, stack: string, id: string, fontFaceFamily?: string): Promise<StyleGlyph> { const tinySDF = await this._getTinySDF(entry, stack, id, fontFaceFamily); const char = tinySDF.draw(id); /** * TinySDF's "top" is the distance from the alphabetic baseline to the top of the glyph. * Server-generated fonts specify "top" relative to an origin above the em box (the origin * comes from FreeType, but I'm unclear on exactly how it's derived) * ref: https://github.com/mapbox/sdf-glyph-foundry * * Server fonts don't yet include baseline information, so we can't line up exactly with them * (and they don't line up with each other) * ref: https://github.com/mapbox/node-fontnik/pull/160 * * To approximately align TinySDF glyphs with server-provided glyphs, we use this baseline adjustment * factor calibrated to be in between DIN Pro and Arial Unicode (but closer to Arial Unicode) */ const topAdjustment = 27.5; const leftAdjustment = 0.5; // By definition, control characters are invisible and nonspacing. const isControl = /^\p{gc=Cf}+$/u.test(id); return { id: id.codePointAt(0), bitmap: new AlphaImage({width: char.width || 30 * textureScale, height: char.height || 30 * textureScale}, char.data), metrics: { width: isControl ? 0 : (char.glyphWidth / textureScale || 24), height: char.glyphHeight / textureScale || 24, left: (char.glyphLeft / textureScale + leftAdjustment) || 0, top: char.glyphTop / textureScale - topAdjustment || -8, advance: isControl ? 0 : (char.glyphAdvance / textureScale || 24), isDoubleResolution: true } }; } /** * Returns the TinySDF that draws this grapheme, created lazily. A stack keeps one per font * selection, so a fallback cannot bleed into the rest of the text, and one more per font file * for the clusters drawn from it, which need a wider canvas. * * A font file carries its own weight and style, so neither is sniffed out of the family name. * Where no file covers the grapheme, `localIdeographFontFamily` beats the last resort fontstack. */ _getTinySDF(entry: Entry, stack: string, id: string, fontFaceFamily?: string): Promise<Rasterizer> { const cluster = isCluster(id); if (fontFaceFamily) { const cache = cluster ? 'clusterTinySDFs' : 'fontFaceTinySDFs'; entry[cache] ??= {}; entry[cache][fontFaceFamily] ||= this._createTinySDF(fontFaceFamily, false, cluster ? clusterEmsWide : 1); return entry[cache][fontFaceFamily]; } const usesLocalIdeographFontFamily = stack === defaultStack && this.localIdeographFontFamily !== '' && this._charUsesLocalIdeographFontFamily(id.codePointAt(0)); const family = usesLocalIdeographFontFamily ? this.localIdeographFontFamily as string : stack; if (cluster) { entry.clusterTinySDFs ??= {}; entry.clusterTinySDFs[family] ||= this._createTinySDF(family, true, clusterEmsWide); return entry.clusterTinySDFs[family]; } const cache = usesLocalIdeographFontFamily ? 'ideographTinySDF' : 'tinySDF'; entry[cache] ||= this._createTinySDF(family); return entry[cache]; } /** * Builds the TinySDF that draws with a given font selection. * * TinySDF derives its canvas from `fontSize + buffer * 4`, so the buffer is the only way in to a * wider one. It also stands for the padding the atlas expects to be `GLYPH_PBF_BORDER`, so the * two are passed separately. * * @param emsWide - how wide the glyphs may be, in font sizes, before TinySDF cuts them off */ async _createTinySDF(stack: String | false, sniffFontStyles: boolean = true, emsWide: number = 1): Promise<Rasterizer> { // Escape and quote the font family list for use in CSS. const fontFamilies = stack ? stack.split(',') : []; fontFamilies.push(defaultGenericFontFamily); const fontFamily = fontFamilies.map(fontName => /[-\w]+/.test(fontName) ? fontName : `'${CSS.escape(fontName)}'` ).join(','); const fontSize = 24 * textureScale; const fontWeight = sniffFontStyles ? this._fontWeight(fontFamilies[0]) : undefined; const fontStyle = sniffFontStyles ? this._fontStyle(fontFamilies[0]) : 'normal'; // Await web font load so TinySDF doesn't cache a fallback bitmap. See #7307. if (typeof document !== 'undefined' && document.fonts?.load) { try { await document.fonts.load(`${fontStyle} ${fontWeight || 'normal'} ${fontSize}px ${fontFamily}`); } catch (e) { warnOnce(`Failed to load font "${fontFamily}": ${ensureError(e).message}`); } } const padding = 3 * textureScale; return this.createRasterizer({ fontSize, buffer: Math.max(padding, Math.ceil(fontSize * (emsWide - 1) / 4)), radius: 8 * textureScale, cutoff: 0.25, fontFamily, fontWeight, fontStyle, lang: this.lang }, padding); } /** * Sniffs the font style out of a font family name. */ _fontStyle(fontFamily: string): string { if (/italic/i.test(fontFamily)) { return 'italic'; } else if (/oblique/i.test(fontFamily)) { return 'oblique'; } return 'normal'; } /** * Sniffs the font weight out of a font family name. */ _fontWeight(fontFamily: string): string { // Based on the OpenType specification // https://learn.microsoft.com/en-us/typography/opentype/spec/os2#usweightclass const weightsByName = { thin: 100, hairline: 100, 'extra light': 200, 'ultra light': 200, light: 300, normal: 400, regular: 400, medium: 500, semibold: 600, demibold: 600, bold: 700, 'extra bold': 800, 'ultra bold': 800, black: 900, heavy: 900, 'extra black': 950, 'ultra black': 950 }; let match; for (const [name, weight] of Object.entries(weightsByName)) { if (new RegExp(`\\b${name}\\b`, 'i').test(fontFamily)) { match = `${weight}`; } } return match; } destroy(): void { for (const stack in this.entries) { const entry = this.entries[stack]; entry.tinySDF = null; entry.ideographTinySDF = null; entry.fontFaceTinySDFs = {}; entry.glyphs = {}; entry.requests = {}; entry.ranges = {}; } this.entries = {}; this.fontFaceManager.destroy(); } }