Skip to content

Commit 4463e3b

Browse files
stepankuzmingithub-actions[bot]
authored andcommitted
Enable client-side fontstack compositing by default
GitOrigin-RevId: d6990296a168dba501e4a79b50d98f6a9bd1002e
1 parent 74316c4 commit 4463e3b

File tree

11 files changed

+117
-27
lines changed

11 files changed

+117
-27
lines changed

debug/render-test.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
const {
4848
fadeDuration = 0,
4949
localIdeographFontFamily = false,
50+
fontstackCompositing,
5051
operations,
5152
scaleFactor = 1,
5253
...options
@@ -107,6 +108,7 @@
107108
transformRequest,
108109
fadeDuration,
109110
localIdeographFontFamily,
111+
fontstackCompositing,
110112
interactive: false,
111113
attributionControl: false,
112114
performanceMetricsCollection: false,

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export type {NavigationControlOptions} from './ui/control/navigation_control';
6565
export type {FullscreenControlOptions} from './ui/control/fullscreen_control';
6666
export type {AttributionControlOptions} from './ui/control/attribution_control';
6767
export type {MapOptions, IControl, ControlPosition} from './ui/map';
68+
export type {FontstackCompositing} from './style/glyph_loader';
6869
export type {AnimationOptions, CameraOptions, EasingOptions} from './ui/camera';
6970

7071
export type {

src/render/glyph_manager.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import config from '../util/config';
55
import {asyncAll, warnOnce} from '../util/util';
66
import {AlphaImage} from '../util/image';
77

8+
import type {FontstackCompositing} from '../style/glyph_loader';
89
import type {Class} from '../types/class';
910
import type {GlyphRange} from '../style/load_glyph_range';
1011
import type {StyleGlyph, StyleGlyphs} from '../style/style_glyph';
@@ -77,7 +78,7 @@ class GlyphManager {
7778

7879
static TinySDF: Class<TinySDF>;
7980

80-
constructor(requestManager: RequestManager, localGlyphMode: number, localFontFamily?: string, useServerFontComposition?: boolean) {
81+
constructor(requestManager: RequestManager, localGlyphMode: number, localFontFamily?: string, fontstackCompositing?: FontstackCompositing) {
8182
this.requestManager = requestManager;
8283
this.localGlyphMode = localGlyphMode;
8384
this.localFontFamily = localFontFamily;
@@ -89,7 +90,7 @@ class GlyphManager {
8990
'500': {},
9091
'900': {}
9192
};
92-
this.glyphLoader = new GlyphLoader({useServerFontComposition});
93+
this.glyphLoader = new GlyphLoader({fontstackCompositing});
9394
}
9495

9596
setURL(url: string) {

src/style/glyph_loader.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,18 @@ import type {StyleGlyphs} from './style_glyph';
66
import type {RequestManager} from '../util/mapbox';
77
import type {Callback} from '../types/callback';
88

9+
/**
10+
* Controls how multi-font fontstacks are composited.
11+
*
12+
* - `'client'` (default): Each font in a comma-separated fontstack is loaded individually
13+
* and missing glyphs are filled from subsequent fallback fonts on the client.
14+
* - `'server'`: The full fontstack string is passed as-is to the glyph server, which must
15+
* support server-side fontstack composition.
16+
*/
17+
export type FontstackCompositing = 'client' | 'server';
18+
919
type GlyphLoaderOptions = {
10-
useServerFontComposition?: boolean;
20+
fontstackCompositing?: FontstackCompositing;
1121
};
1222

1323
/*
@@ -24,15 +34,15 @@ class GlyphLoader {
2434

2535
private cachedRanges: Map<string, GlyphRange | null>;
2636

27-
private useServerFontComposition: boolean;
37+
private fontstackCompositing: FontstackCompositing;
2838

2939
// Exposed as static to enable stubbing in unit tests
3040
static loadGlyphRange: typeof loadGlyphRange;
3141

3242
constructor(options?: GlyphLoaderOptions) {
3343
this.pendingRequests = new Map();
3444
this.cachedRanges = new Map();
35-
this.useServerFontComposition = options && options.useServerFontComposition !== undefined ? options.useServerFontComposition : true;
45+
this.fontstackCompositing = (options && options.fontstackCompositing) || 'client';
3646
}
3747

3848
loadGlyphRange(
@@ -42,7 +52,7 @@ class GlyphLoader {
4252
requestManager: RequestManager,
4353
callback: Callback<GlyphRange>
4454
): void {
45-
if (this.useServerFontComposition) {
55+
if (this.fontstackCompositing === 'server') {
4656
GlyphLoader.loadGlyphRange(fontstack, range, urlTemplate, requestManager, callback);
4757
return;
4858
}

src/style/style.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ import {ImageProvider} from '../render/image_provider';
7373
import IndoorManager from './indoor_manager';
7474
import {StyleBOMUtils} from './style_bom_utils';
7575

76+
import type {FontstackCompositing} from './glyph_loader';
7677
import type {PropertyValidatorOptions} from '../style-spec/validate/validate_property';
7778
import type Tile from '../source/tile';
7879
import type GeoJSONSource from '../source/geojson_source';
@@ -228,7 +229,7 @@ export type StyleOptions = {
228229
validate?: boolean;
229230
localFontFamily?: string | null | undefined;
230231
localIdeographFontFamily?: string;
231-
useServerFontComposition?: boolean;
232+
fontstackCompositing?: FontstackCompositing;
232233
dispatcher?: Dispatcher;
233234
imageManager?: ImageManager;
234235
glyphManager?: GlyphManager;
@@ -465,7 +466,7 @@ class Style extends Evented<MapEvents> {
465466
LocalGlyphMode.all :
466467
(options.localIdeographFontFamily ? LocalGlyphMode.ideographs : LocalGlyphMode.none),
467468
options.localFontFamily || options.localIdeographFontFamily,
468-
options.useServerFontComposition);
469+
options.fontstackCompositing);
469470
}
470471

471472
if (options.modelManager) {

src/ui/map.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ import type {LngLatLike, LngLatBoundsLike} from '../geo/lng_lat';
6060
import type {CustomLayerInterface} from '../style/style_layer/custom_style_layer';
6161
import type {StyleImageInterface, StyleImageMetadata} from '../style/style_image';
6262
import type {StyleOptions, StyleSetterOptions, AnyLayer, FeatureSelector, SourceSelector, QueryRenderedFeaturesParams, QueryRenderedFeaturesetParams} from '../style/style';
63+
import type {FontstackCompositing} from '../style/glyph_loader';
6364
import type ScrollZoomHandler from './handler/scroll_zoom';
6465
import type {ScrollZoomHandlerOptions} from './handler/scroll_zoom';
6566
import type BoxZoomHandler from './handler/box_zoom';
@@ -126,7 +127,7 @@ export type SetStyleOptions = {
126127
};
127128
localFontFamily: StyleOptions['localFontFamily'];
128129
localIdeographFontFamily: StyleOptions['localIdeographFontFamily'];
129-
useServerFontComposition?: StyleOptions['useServerFontComposition'];
130+
fontstackCompositing?: FontstackCompositing;
130131
};
131132

132133
type Listener<T extends MapEventType> = (event: MapEventOf<T>) => void;
@@ -212,7 +213,7 @@ export type MapOptions = {
212213
fadeDuration?: number;
213214
localFontFamily?: string;
214215
localIdeographFontFamily?: string;
215-
useServerFontComposition?: boolean;
216+
fontstackCompositing?: FontstackCompositing;
216217
performanceMetricsCollection?: boolean;
217218
tessellationStep?: number;
218219
scaleFactor?: number;
@@ -267,7 +268,7 @@ const defaultOptions = {
267268
maxTileCacheSize: null,
268269
localIdeographFontFamily: 'sans-serif',
269270
localFontFamily: null,
270-
useServerFontComposition: true,
271+
fontstackCompositing: 'client',
271272
transformRequest: null,
272273
accessToken: null,
273274
fadeDuration: 300,
@@ -392,6 +393,9 @@ const defaultOptions = {
392393
* @param {string} [options.localFontFamily=null] Defines a CSS
393394
* font-family for locally overriding generation of all glyphs. Font settings from the map's style will be ignored, except for font-weight keywords (light/regular/medium/bold).
394395
* If set, this option overrides the setting in localIdeographFontFamily.
396+
* @param {'client' | 'server'} [options.fontstackCompositing='client'] Controls how multi-font fontstacks are composited.
397+
* When `'client'` (the default), each font in a comma-separated fontstack is loaded individually and missing glyphs are filled from subsequent fallback fonts on the client.
398+
* When `'server'`, the full fontstack string is passed as-is to the glyph server, which must support server-side fontstack composition.
395399
* @param {RequestTransformFunction} [options.transformRequest=null] A callback run before the Map makes a request for an external URL. The callback can be used to modify the url, set headers, or set the credentials property for cross-origin requests.
396400
* Expected to return a {@link RequestParameters} object with a `url` property and optionally `headers` and `credentials` properties.
397401
* @param {boolean} [options.collectResourceTiming=false] If `true`, Resource Timing API information will be collected for requests made by GeoJSON and Vector Tile web workers (this information is normally inaccessible from the main Javascript thread). Information will be returned in a `resourceTiming` property of relevant `data` events.
@@ -498,7 +502,7 @@ export class Map extends Camera {
498502
_mapId: number;
499503
_localIdeographFontFamily: string;
500504
_localFontFamily?: string;
501-
_useServerFontComposition?: boolean;
505+
_fontstackCompositing: FontstackCompositing;
502506
_requestManager: RequestManager;
503507
_locale: Partial<typeof defaultLocale>;
504508
_removed: boolean;
@@ -728,15 +732,15 @@ export class Map extends Camera {
728732

729733
this._localFontFamily = options.localFontFamily;
730734
this._localIdeographFontFamily = options.localIdeographFontFamily;
731-
this._useServerFontComposition = options.useServerFontComposition;
735+
this._fontstackCompositing = options.fontstackCompositing;
732736

733737
if (options.style || !options.testMode) {
734738
const style = options.style || config.DEFAULT_STYLE;
735739
this.setStyle(style, {
736740
config: options.config,
737741
localFontFamily: this._localFontFamily,
738742
localIdeographFontFamily: this._localIdeographFontFamily,
739-
useServerFontComposition: this._useServerFontComposition
743+
fontstackCompositing: this._fontstackCompositing
740744
});
741745
}
742746

@@ -2334,12 +2338,13 @@ export class Map extends Camera {
23342338
* });
23352339
*/
23362340
setStyle(style: StyleSpecification | string | null, options?: SetStyleOptions): this {
2337-
options = Object.assign({}, {localIdeographFontFamily: this._localIdeographFontFamily, localFontFamily: this._localFontFamily, useServerFontComposition: this._useServerFontComposition}, options);
2341+
options = Object.assign({}, {localIdeographFontFamily: this._localIdeographFontFamily, localFontFamily: this._localFontFamily, fontstackCompositing: this._fontstackCompositing}, options);
23382342

23392343
const diffNeeded =
23402344
options.diff !== false &&
23412345
options.localFontFamily === this._localFontFamily &&
23422346
options.localIdeographFontFamily === this._localIdeographFontFamily &&
2347+
options.fontstackCompositing === this._fontstackCompositing &&
23432348
!options.config; // Rebuild the style from scratch if config is set
23442349

23452350
if (this.style && style && diffNeeded) {
@@ -2363,6 +2368,7 @@ export class Map extends Camera {
23632368
} else {
23642369
this._localIdeographFontFamily = options.localIdeographFontFamily;
23652370
this._localFontFamily = options.localFontFamily;
2371+
this._fontstackCompositing = options.fontstackCompositing;
23662372
return this._updateStyle(style, options);
23672373
}
23682374
}

test/integration/render-tests/front-cutoff/nyc-buildings/style.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"width": 1024,
66
"height": 768,
77
"allowed": 0.005,
8+
"fontstackCompositing": "server",
89
"operations": [
910
[
1011
"setStyle",

test/integration/render-tests/regressions/mapbox-gl-native#11729/style.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
"metadata": {
44
"test": {
55
"width": 256,
6-
"height": 256
6+
"height": 256,
7+
"fontstackCompositing": "server"
78
}
89
},
910
"sources": {

test/integration/render-tests/utils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ export async function renderMap(style, options, currentTestName) {
150150
scaleFactor: options.scaleFactor || 1,
151151
fadeDuration: options.fadeDuration || 0,
152152
localIdeographFontFamily: options.localIdeographFontFamily || false,
153+
fontstackCompositing: options.fontstackCompositing,
153154
projection: options.projection,
154155
precompilePrograms: false,
155156
crossSourceCollisions: typeof options.crossSourceCollisions === "undefined" ? true : options.crossSourceCollisions,

test/unit/style/glyph_loader.test.ts

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,33 @@ const createLoadGlyphRangeStub = (impl: typeof GlyphLoader.loadGlyphRange) => {
3030
return vi.spyOn(GlyphLoader, 'loadGlyphRange').mockImplementation(impl);
3131
};
3232

33+
test('GlyphLoader defaults to client-side composition when constructed without options', async () => {
34+
const primaryData = createMockGlyphRange([65, 66]);
35+
const fallbackData = createMockGlyphRange([67]);
36+
const stub = createLoadGlyphRangeStub(
37+
(font: string, _range: number, _url: string, _rm: RequestManager, cb: Callback<GlyphRange>) => {
38+
setTimeout(() => {
39+
if (font === 'Primary') cb(null, primaryData);
40+
else cb(null, fallbackData);
41+
}, 0);
42+
}
43+
);
44+
45+
const loader = new GlyphLoader();
46+
47+
await new Promise<void>(resolve => {
48+
loader.loadGlyphRange('Primary,Fallback', 0, urlTemplate, mockRequestManager, (err, result) => {
49+
expect(err).toBeFalsy();
50+
expect(result).toBeTruthy();
51+
// Two per-font requests prove client-side composition was chosen, not a single server-side request.
52+
expect(stub).toHaveBeenCalledTimes(2);
53+
expect(stub).toHaveBeenCalledWith('Primary', 0, urlTemplate, mockRequestManager, expect.any(Function));
54+
expect(stub).toHaveBeenCalledWith('Fallback', 0, urlTemplate, mockRequestManager, expect.any(Function));
55+
resolve();
56+
});
57+
});
58+
});
59+
3360
test('GlyphLoader loads single font directly', async () => {
3461
const mockData = createMockGlyphRange([65, 66, 67]);
3562
const stub = createLoadGlyphRangeStub(
@@ -39,7 +66,7 @@ test('GlyphLoader loads single font directly', async () => {
3966
);
4067

4168
const loader = new GlyphLoader({
42-
useServerFontComposition: false
69+
fontstackCompositing: 'client'
4370
});
4471

4572
await new Promise<void>(resolve => {
@@ -77,7 +104,7 @@ test('GlyphLoader performs client-side composition with multiple fonts', async (
77104
);
78105

79106
const loader = new GlyphLoader({
80-
useServerFontComposition: false
107+
fontstackCompositing: 'client'
81108
});
82109

83110
await new Promise<void>(resolve => {
@@ -109,7 +136,7 @@ test('GlyphLoader deduplicates concurrent requests for same font/range', async (
109136
);
110137

111138
const loader = new GlyphLoader({
112-
useServerFontComposition: false
139+
fontstackCompositing: 'client'
113140
});
114141
const results: Array<GlyphRange | null | undefined> = [];
115142

@@ -157,7 +184,7 @@ test('GlyphLoader deduplicates shared fallback fonts across different fontstacks
157184
);
158185

159186
const loader = new GlyphLoader({
160-
useServerFontComposition: false
187+
fontstackCompositing: 'client'
161188
});
162189

163190
await new Promise<void>(resolve => {
@@ -202,7 +229,7 @@ test('GlyphLoader caches completed requests', async () => {
202229
);
203230

204231
const loader = new GlyphLoader({
205-
useServerFontComposition: false
232+
fontstackCompositing: 'client'
206233
});
207234

208235
// First request
@@ -244,7 +271,7 @@ test('GlyphLoader handles partial results when one font fails', async () => {
244271
);
245272

246273
const loader = new GlyphLoader({
247-
useServerFontComposition: false
274+
fontstackCompositing: 'client'
248275
});
249276

250277
await new Promise<void>(resolve => {
@@ -267,7 +294,7 @@ test('GlyphLoader returns error when all fonts fail', async () => {
267294
);
268295

269296
const loader = new GlyphLoader({
270-
useServerFontComposition: false
297+
fontstackCompositing: 'client'
271298
});
272299

273300
await new Promise<void>(resolve => {
@@ -279,15 +306,15 @@ test('GlyphLoader returns error when all fonts fail', async () => {
279306
});
280307
});
281308

282-
test('GlyphLoader uses server composition when useServerFontComposition is true', async () => {
309+
test('GlyphLoader uses server composition when fontstackCompositing is \'server\'', async () => {
283310
const mockData = createMockGlyphRange([65, 66]);
284311
const stub = createLoadGlyphRangeStub(
285312
(_font: string, _range: number, _url: string, _rm: RequestManager, cb: Callback<GlyphRange>) => {
286313
setTimeout(() => cb(null, mockData), 0);
287314
}
288315
);
289316

290-
const loader = new GlyphLoader({useServerFontComposition: true});
317+
const loader = new GlyphLoader({fontstackCompositing: 'server'});
291318

292319
await new Promise<void>(resolve => {
293320
loader.loadGlyphRange('Primary,Fallback', 0, urlTemplate, mockRequestManager, (err, result) => {
@@ -311,7 +338,7 @@ test('GlyphLoader trims whitespace from font names', async () => {
311338
}
312339
);
313340

314-
const loader = new GlyphLoader({useServerFontComposition: false});
341+
const loader = new GlyphLoader({fontstackCompositing: 'client'});
315342

316343
await new Promise<void>(resolve => {
317344
loader.loadGlyphRange(' Arial , Fallback ', 0, urlTemplate, mockRequestManager, (err, result) => {
@@ -344,7 +371,7 @@ test('GlyphLoader uses ascender/descender from first font that provides them', a
344371
);
345372

346373
const loader = new GlyphLoader({
347-
useServerFontComposition: false
374+
fontstackCompositing: 'client'
348375
});
349376

350377
await new Promise<void>(resolve => {

0 commit comments

Comments
 (0)