maplibre-gl
Version:
BSD licensed community fork of mapbox-gl, a WebGL interactive maps library
451 lines (354 loc) • 21.5 kB
text/typescript
import {describe, beforeEach, afterEach, test, expect, vi} from 'vitest';
import {parseGlyphPbf} from '../style/parse_glyph_pbf.ts';
import {GlyphManager} from './glyph_manager.ts';
import fs from 'fs';
import path from 'path';
import {RequestManager} from '../util/request_manager.ts';
import {fakeServer, type FakeServer} from 'nise';
import {bufferToArrayBuffer} from '../util/test/util.ts';
import TinySDF, {type TinySDFOptions} from '@mapbox/tiny-sdf';
import type {CreateRasterizer} from './glyph_manager.ts';
describe('GlyphManager', () => {
const pbf = fs.readFileSync(path.join(__dirname, '../../test/unit/assets/0-255.pbf'));
const GLYPHS = {};
for (const glyph of parseGlyphPbf(pbf)) {
GLYPHS[glyph.id] = glyph;
}
const identityTransform = new RequestManager();
let server: FakeServer;
beforeEach(() => {
global.fetch = null;
server = fakeServer.create({autoRespond: true, autoRespondAfter: 0});
});
function char(codePoint: number) {
return String.fromCodePoint(codePoint);
}
function createGlyphManager(
remoteEnabled: boolean,
font?: string | false,
language?: string,
createRasterizer?: CreateRasterizer
): GlyphManager {
const manager = new GlyphManager(identityTransform, font, language, createRasterizer);
if (remoteEnabled) {
manager.setURL('https://localhost/fonts/v1/{fontstack}/{range}.pbf');
}
return manager;
}
function serveGlyphRanges() {
server.respondWith(/\.pbf$/, function (request) {
request.respond(200, undefined, bufferToArrayBuffer(pbf) as unknown as string);
});
}
function glyphRangeRequests() {
return server.requests.filter(request => request.url.endsWith('.pbf'));
}
function fakeRasterizer(draw: (text: string) => any = () => GLYPHS[0]) {
return vi.fn((_options: TinySDFOptions, padding: number) => ({draw, buffer: padding}));
}
afterEach(() => {
vi.clearAllMocks();
server.restore();
delete (document as any).fonts;
});
test('GlyphManager requests 0-255 PBF', async () => {
serveGlyphRanges();
const transformRequest = vi.fn((url: string) => ({url}));
const manager = new GlyphManager(new RequestManager(transformRequest));
manager.setURL('https://localhost/fonts/v1/{fontstack}/{range}.pbf');
const returnedGlyphs = await manager.getGlyphs({'Arial Unicode MS': [char(55)]});
expect(returnedGlyphs['Arial Unicode MS'][char(55)].metrics.advance).toBe(12);
expect(transformRequest).toHaveBeenCalledExactlyOnceWith(
'https://localhost/fonts/v1/Arial Unicode MS/0-255.pbf', 'Glyphs');
});
test('GlyphManager doesn\'t request twice 0-255 PBF if a glyph is missing', async () => {
serveGlyphRanges();
const manager = createGlyphManager(true);
await manager.getGlyphs({'Arial Unicode MS': [char(0x01)]});
expect(manager.entries['Arial Unicode MS'].ranges[0]).toBe(true);
expect(glyphRangeRequests()).toHaveLength(1);
// We remove all requests as in getGlyphs code.
delete manager.entries['Arial Unicode MS'].requests[0];
await manager.getGlyphs({'Arial Unicode MS': [char(0x01)]});
expect(manager.entries['Arial Unicode MS'].ranges[0]).toBe(true);
expect(glyphRangeRequests()).toHaveLength(1);
});
test('GlyphManager requests remote CJK PBF', async () => {
serveGlyphRanges();
const manager = createGlyphManager(true);
const returnedGlyphs = await manager.getGlyphs({'Arial Unicode MS': [char(0x5e73)]});
expect(returnedGlyphs['Arial Unicode MS'][char(0x5e73)]).toBeNull(); // The fixture returns a PBF without the glyph we requested
});
test('GlyphManager requests remote non-BMP, non-CJK PBF', async () => {
serveGlyphRanges();
const manager = createGlyphManager(true);
// Request Egyptian hieroglyph 𓃰
const returnedGlyphs = await manager.getGlyphs({'Arial Unicode MS': [char(0x1e0f0)]});
expect(returnedGlyphs['Arial Unicode MS'][char(0x1e0f0)]).toBeNull(); // The fixture returns a PBF without the glyph we requested
});
test('GlyphManager does not cache CJK chars that should be rendered locally', async () => {
serveGlyphRanges();
const manager = createGlyphManager(true, 'sans-serif');
//Request char that overlaps Katakana range
let returnedGlyphs = await manager.getGlyphs({'Arial Unicode MS': [char(0x3005)]});
expect(returnedGlyphs['Arial Unicode MS'][char(0x3005)]).not.toBeNull();
//Request char from Katakana range (te テ)
returnedGlyphs = await manager.getGlyphs({'Arial Unicode MS': [char(0x30C6)]});
const glyph = returnedGlyphs['Arial Unicode MS'][char(0x30c6)];
//Ensure that te is locally generated.
expect(glyph.bitmap.height).toBe(12);
expect(glyph.bitmap.width).toBe(12);
});
test('GlyphManager generates CJK PBF locally', async () => {
const manager = createGlyphManager(true, 'sans-serif');
// Chinese character píng 平
const returnedGlyphs = await manager.getGlyphs({'Arial Unicode MS': [char(0x5e73)]});
expect(returnedGlyphs['Arial Unicode MS'][char(0x5e73)].metrics.advance).toBe(0.5);
});
test('GlyphManager generates non-BMP CJK PBF locally', async () => {
const manager = createGlyphManager(true, 'sans-serif');
// Chinese character biáng 𰻞
const returnedGlyphs = await manager.getGlyphs({'Arial Unicode MS': [char(0x30EDE)]});
expect(returnedGlyphs['Arial Unicode MS'][char(0x30EDE)].metrics.advance).toBe(1);
});
test('GlyphManager generates Katakana PBF locally', async () => {
const manager = createGlyphManager(true, 'sans-serif');
// Katakana letter te テ
const returnedGlyphs = await manager.getGlyphs({'Arial Unicode MS': [char(0x30c6)]});
expect(returnedGlyphs['Arial Unicode MS'][char(0x30c6)].metrics.advance).toBe(0.5);
});
test('GlyphManager generates Hiragana PBF locally', async () => {
const manager = createGlyphManager(true, 'sans-serif');
//Hiragana letter te て
const returnedGlyphs = await manager.getGlyphs({'Arial Unicode MS': [char(0x3066)]});
expect(returnedGlyphs['Arial Unicode MS'][char(0x3066)].metrics.advance).toBe(0.5);
});
test('GlyphManager consistently generates CJKV text locally', async () => {
const manager = createGlyphManager(true, 'sans-serif');
// Space
expect(manager._charUsesLocalIdeographFontFamily(0x0020)).toBe(false);
// Chinese character píng 平
expect(manager._charUsesLocalIdeographFontFamily(0x5e73)).toBe(true);
// Chinese character biáng 𰻞
expect(manager._charUsesLocalIdeographFontFamily(0x30EDE)).toBe(true);
// Katakana letter te テ
expect(manager._charUsesLocalIdeographFontFamily(0x30c6)).toBe(true);
// Hiragana letter te て
expect(manager._charUsesLocalIdeographFontFamily(0x3066)).toBe(true);
// Hangul letter a 아
expect(manager._charUsesLocalIdeographFontFamily(0xC544)).toBe(true);
// Japanese full-width dash ー
expect(manager._charUsesLocalIdeographFontFamily(0x30FC)).toBe(true);
// Halfwidth and Fullwidth Forms: full-width exclamation !
expect(manager._charUsesLocalIdeographFontFamily(0xFF01)).toBe(true);
// CJK Symbols and Punctuation: Japanese Post mark 〒
expect(manager._charUsesLocalIdeographFontFamily(0x3012)).toBe(true);
});
test('GlyphManager locally generates Latin character', async () => {
const manager = createGlyphManager(false, 'sans-serif');
// A
const returnedGlyphs = await manager.getGlyphs({'Times Old Roman': [char(0x41)]});
expect(returnedGlyphs['Times Old Roman'][char(0x41)].metrics.width).toBeGreaterThan(0);
expect(returnedGlyphs['Times Old Roman'][char(0x41)].metrics.advance).toBeGreaterThan(0);
});
test('GlyphManager locally generates nonspacing control character', async () => {
const manager = createGlyphManager(false, 'sans-serif');
// U+202E RIGHT-TO-LEFT OVERRIDE
const returnedGlyphs = await manager.getGlyphs({'Ctrl Alt Del': [char(0x202e)]});
expect(returnedGlyphs['Ctrl Alt Del'][char(0x202e)].metrics.width).toBe(0);
expect(returnedGlyphs['Ctrl Alt Del'][char(0x202e)].metrics.advance).toBe(0);
});
test('GlyphManager locally generates a grapheme cluster when the style has no glyphs URL', async () => {
const manager = createGlyphManager(false, 'sans-serif');
// \u0926\u093f is Devanagari DA with the vowel sign I, which is written as one shape
const cluster = '\u0926\u093f';
const returnedGlyphs = await manager.getGlyphs({'Noto Sans': [cluster]});
expect(returnedGlyphs['Noto Sans'][cluster].metrics.advance).toBeGreaterThan(0);
});
test('GlyphManager locally generates a grapheme cluster the glyphs URL has no way to serve', async () => {
const manager = createGlyphManager(true, 'sans-serif');
const cluster = '\u0926\u093f';
const returnedGlyphs = await manager.getGlyphs({'Noto Sans': [cluster]});
expect(returnedGlyphs['Noto Sans'][cluster].metrics.advance).toBeGreaterThan(0);
});
test('GlyphManager matches font styles', async () => {
const manager = createGlyphManager(false, 'sans-serif');
expect(manager._fontStyle('Swiss Italic')).toBe('italic');
expect(manager._fontStyle('Swiss Oblique')).toBe('oblique');
expect(manager._fontStyle('Swiss Roman')).toBe('normal');
expect(manager._fontStyle('Swiss Cursive')).toBe('normal');
});
test('GlyphManager matches font weights', async () => {
const manager = createGlyphManager(false, 'sans-serif');
expect(manager._fontWeight('Swiss Thin')).toBe('100');
expect(manager._fontWeight('Swiss Regular')).toBe('400');
expect(manager._fontWeight('Swiss Bold')).toBe('700');
expect(manager._fontWeight('Swiss Extra Bold')).toBe('800');
expect(manager._fontWeight('Swiss Cheese')).toBeUndefined();
});
test('GlyphManager generates missing PBF locally', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
server.respondWith(function (request) { request.respond(404, undefined, 'Not Found'); });
const manager = createGlyphManager(true, 'sans-serif');
const returnedGlyphs = await manager.getGlyphs({'Arial Unicode MS': [char(0x10e1)]});
expect(returnedGlyphs['Arial Unicode MS'][char(0x10e1)].metrics.advance).toBeGreaterThan(0);
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('Unable to load glyph range'));
});
test('GlyphManager caches locally generated glyphs', async () => {
const manager = createGlyphManager(true, 'sans-serif');
const drawSpy = vi.spyOn(TinySDF.prototype, 'draw').mockReturnValue({data: new Uint8ClampedArray(60 * 60)} as any);
// Katakana letter te
const returnedGlyphs = await manager.getGlyphs({'Arial Unicode MS': [char(0x30c6)]});
expect(returnedGlyphs['Arial Unicode MS'][char(0x30c6)].metrics.advance).toBe(24);
await manager.getGlyphs({'Arial Unicode MS': [char(0x30c6)]});
expect(drawSpy).toHaveBeenCalledTimes(1);
});
test('GlyphManager passes no language to TinySDF by default', async () => {
const createRasterizer = fakeRasterizer();
const manager = createGlyphManager(true, 'sans-serif', undefined, createRasterizer);
await manager.getGlyphs({'Arial Unicode MS': [char(0x30c6)]});
expect(createRasterizer).toHaveBeenCalledWith(expect.not.objectContaining({lang: expect.anything()}), expect.any(Number));
});
test('GlyphManager sets the language on TinySDF', async () => {
const createRasterizer = fakeRasterizer();
const manager = createGlyphManager(true, 'sans-serif', 'zh', createRasterizer);
await manager.getGlyphs({'Arial Unicode MS': [char(0x30c6)]});
expect(createRasterizer).toHaveBeenCalledWith(expect.objectContaining({lang: 'zh'}), expect.any(Number));
});
test('awaits document.fonts.load before instantiating TinySDF', async () => {
const loadSpy = vi.fn(() => Promise.resolve([]));
Object.defineProperty(document, 'fonts', {configurable: true, value: {load: loadSpy}});
const createRasterizer = fakeRasterizer();
const manager = createGlyphManager(false, 'sans-serif', undefined, createRasterizer);
await manager.getGlyphs({'Arial Unicode MS': [char(0x41)]});
expect(loadSpy).toHaveBeenCalledTimes(1);
expect(createRasterizer).toHaveBeenCalledTimes(1);
expect(loadSpy.mock.invocationCallOrder[0]).toBeLessThan(createRasterizer.mock.invocationCallOrder[0]);
});
test('still instantiates TinySDF when document.fonts.load rejects', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
const loadSpy = vi.fn(() => Promise.reject(new Error('font not found')));
Object.defineProperty(document, 'fonts', {configurable: true, value: {load: loadSpy}});
const createRasterizer = fakeRasterizer();
const manager = createGlyphManager(false, 'sans-serif', undefined, createRasterizer);
const result = await manager.getGlyphs({'Arial Unicode MS': [char(0x41)]});
expect(loadSpy).toHaveBeenCalledTimes(1);
expect(createRasterizer).toHaveBeenCalledTimes(1);
expect(result['Arial Unicode MS'][char(0x41)]).toBeDefined();
});
test('memoizes document.fonts.load per fontstack', async () => {
const loadSpy = vi.fn(() => Promise.resolve([]));
Object.defineProperty(document, 'fonts', {configurable: true, value: {load: loadSpy}});
const createRasterizer = fakeRasterizer();
const manager = createGlyphManager(false, 'sans-serif', undefined, createRasterizer);
await manager.getGlyphs({'Arial Unicode MS': [char(0x41)]});
await manager.getGlyphs({'Arial Unicode MS': [char(0x42)]});
await manager.getGlyphs({'Arial Unicode MS': [char(0x43)]});
expect(loadSpy).toHaveBeenCalledTimes(1);
});
describe('font-faces', () => {
function stubFontFaces() {
Object.defineProperty(document, 'fonts', {
configurable: true,
value: {load: () => Promise.resolve([]), add: () => {}, delete: () => {}}
});
(globalThis as any).FontFace = class {
family: string;
constructor(family: string) { this.family = family; }
load = () => Promise.resolve(this);
};
server.respondWith(/\.ttf$/, function (request) { request.respond(200, undefined, 'font file'); });
}
afterEach(() => {
delete (globalThis as any).FontFace;
});
test('draws a covered codepoint with the declared font file instead of downloading a range', async () => {
stubFontFaces();
serveGlyphRanges();
const createRasterizer = fakeRasterizer();
const manager = createGlyphManager(true, undefined, undefined, createRasterizer);
manager.setFontFaces({'Arial Unicode MS': [{url: 'https://localhost/khmer.ttf', 'unicode-range': ['U+1780-17FF']}]});
const returnedGlyphs = await manager.getGlyphs({'Arial Unicode MS': [char(0x1780)]});
expect(returnedGlyphs['Arial Unicode MS'][char(0x1780)]).toBeDefined();
expect(glyphRangeRequests()).toHaveLength(0);
expect(createRasterizer).toHaveBeenCalledWith(expect.objectContaining({
fontFamily: expect.stringMatching(/^maplibre-gl-font-face-\d+,sans-serif$/)
}), expect.any(Number));
});
test('draws a grapheme cluster as one glyph, from the file covering the letter it starts with', async () => {
stubFontFaces();
serveGlyphRanges();
const drawn: string[] = [];
const createRasterizer = fakeRasterizer((text) => { drawn.push(text); return GLYPHS[0]; });
const manager = createGlyphManager(true, undefined, undefined, createRasterizer);
manager.setFontFaces({'Arial Unicode MS': 'https://localhost/hebrew.ttf'});
const shinWithShevaAndDot = '\u05E9\u05B0\u05C1';
const returnedGlyphs = await manager.getGlyphs({'Arial Unicode MS': [shinWithShevaAndDot]});
expect(returnedGlyphs['Arial Unicode MS'][shinWithShevaAndDot]).toBeDefined();
expect(drawn).toContain(shinWithShevaAndDot);
expect(glyphRangeRequests()).toHaveLength(0);
});
test('draws a cluster the declared files do not cover from the local fonts instead', async () => {
stubFontFaces();
serveGlyphRanges();
const createRasterizer = fakeRasterizer();
const manager = createGlyphManager(true, undefined, undefined, createRasterizer);
const khmerOnly = [{url: 'https://localhost/khmer.ttf', 'unicode-range': ['U+1780-17FF']}];
manager.setFontFaces({'Arial Unicode MS': khmerOnly});
const shinWithShevaAndDot = '\u05E9\u05B0\u05C1';
const returnedGlyphs = await manager.getGlyphs({'Arial Unicode MS': [shinWithShevaAndDot]});
expect(returnedGlyphs['Arial Unicode MS'][shinWithShevaAndDot]).not.toBeNull();
expect(createRasterizer).toHaveBeenCalled();
});
test('leaves a codepoint outside every declared range to the glyphs URL', async () => {
stubFontFaces();
serveGlyphRanges();
const manager = createGlyphManager(true);
manager.setFontFaces({'Arial Unicode MS': [{url: 'https://localhost/khmer.ttf', 'unicode-range': ['U+1780-17FF']}]});
const returnedGlyphs = await manager.getGlyphs({'Arial Unicode MS': [char(55)]});
expect(returnedGlyphs['Arial Unicode MS'][char(55)].metrics.advance).toBe(12);
expect(glyphRangeRequests()).toHaveLength(1);
});
test('does not sniff a weight or a style out of the font name, which the file already carries', async () => {
stubFontFaces();
const createRasterizer = fakeRasterizer();
const manager = createGlyphManager(false, undefined, undefined, createRasterizer);
manager.setFontFaces({'Noto Sans Bold Italic': 'https://localhost/noto-bold-italic.ttf'});
await manager.getGlyphs({'Noto Sans Bold Italic': [char(0x41)]});
expect(createRasterizer).toHaveBeenCalledWith(expect.objectContaining({fontWeight: undefined, fontStyle: 'normal'}), expect.any(Number));
});
test('keeps one TinySDF per declared file so a fallback cannot bleed into the rest of the text', async () => {
stubFontFaces();
const createRasterizer = fakeRasterizer();
const manager = createGlyphManager(false, undefined, undefined, createRasterizer);
manager.setFontFaces({'Arial Unicode MS': [
{url: 'https://localhost/khmer.ttf', 'unicode-range': ['U+1780-17FF']},
{url: 'https://localhost/devanagari.ttf', 'unicode-range': ['U+0900-097F']}
]});
await manager.getGlyphs({'Arial Unicode MS': [char(0x1780), char(0x1781), char(0x0915)]});
const families = createRasterizer.mock.calls.map(([options]) => options.fontFamily);
expect(new Set(families).size).toBe(2);
});
test('redraws with the new font faces rather than serving the glyphs cached from the old ones', async () => {
stubFontFaces();
const createRasterizer = fakeRasterizer();
const manager = createGlyphManager(false, undefined, undefined, createRasterizer);
manager.setFontFaces({'Arial Unicode MS': 'https://localhost/noto.ttf'});
await manager.getGlyphs({'Arial Unicode MS': [char(0x41)]});
manager.setFontFaces({'Arial Unicode MS': 'https://localhost/other.ttf'});
await manager.getGlyphs({'Arial Unicode MS': [char(0x41)]});
const families = createRasterizer.mock.calls.map(([options]) => options.fontFamily);
expect(families).toHaveLength(2);
expect(families[0]).not.toBe(families[1]);
});
test('gives a Burmese syllable, drawn nearly twice as wide as one character, a canvas it is not cut off by', async () => {
stubFontFaces();
const createRasterizer = fakeRasterizer();
const manager = createGlyphManager(false, undefined, undefined, createRasterizer);
manager.setFontFaces({'Arial Unicode MS': 'https://localhost/myanmar.ttf'});
await manager.getGlyphs({'Arial Unicode MS': ['\u101C\u102C\u1038', char(0x41)]});
const buffers = createRasterizer.mock.calls.map(([options]) => options.buffer);
expect(Math.max(...buffers)).toBeGreaterThan(Math.min(...buffers));
});
});
});