• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

visgl / deck.gl / 23921223794

02 Apr 2026 08:46PM UTC coverage: 80.339% (-0.05%) from 80.387%
23921223794

Pull #10164

github

web-flow
Merge 6b0e48d35 into 2ff450d21
Pull Request #10164: feat(layers): TextLayer uses real text metrics

3115 of 3762 branches covered (82.8%)

Branch coverage included in aggregate %.

67 of 70 new or added lines in 4 files covered. (95.71%)

28 existing lines in 6 files now uncovered.

14235 of 17834 relevant lines covered (79.82%)

26684.89 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

88.24
/modules/layers/src/text-layer/utils.ts
1
// deck.gl
2
// SPDX-License-Identifier: MIT
3
// Copyright (c) vis.gl contributors
4

5
/* eslint-disable max-statements, max-params, complexity, max-depth */
6
// TODO merge with icon-layer/icon-manager
7
import {log} from '@deck.gl/core';
8
import type {NumericArray} from '@math.gl/core';
9

10
const MISSING_CHAR_WIDTH = 32;
4✔
11
const SINGLE_LINE = [];
4✔
12

13
export type Character = {
14
  x: number;
15
  y: number;
16
  width: number;
17
  height: number;
18
  anchorX: number;
19
  anchorY: number;
20
  advance: number;
21
};
22

23
export type CharacterMapping = Record<string, Character>;
24

25
export function nextPowOfTwo(number: number): number {
26
  return Math.pow(2, Math.ceil(Math.log2(number)));
13✔
27
}
28

29
/**
30
 * Generate character mapping table or update from an existing mapping table
31
 */
32
export function buildMapping({
33
  characterSet,
34
  measureText,
35
  buffer,
36
  maxCanvasWidth,
37
  mapping = {},
38
  xOffset = 0,
39
  yOffsetMin = 0,
40
  yOffsetMax = 0
41
}: {
42
  /** list of characters */
43
  characterSet: Set<string>;
44
  /** function to get width of each character */
45
  measureText: (char: string) => {advance: number; width: number; ascent: number; descent: number};
46
  /** bleeding buffer surround each character */
47
  buffer: number;
48
  /** max width of font atlas */
49
  maxCanvasWidth: number;
50
  /** cached mapping table */
51
  mapping?: CharacterMapping;
52
  /** x position of last character in the existing mapping table */
53
  xOffset?: number;
54
  /** y position of last character in the existing mapping table */
55
  yOffsetMin?: number;
56
  /** bottom position of any character in the existing mapping table */
57
  yOffsetMax?: number;
58
}): {
59
  /** new mapping table */
60
  mapping: CharacterMapping;
61
  /** x position of last character in the new mapping table */
62
  xOffset: number;
63
  /** y position of last character in the new mapping table */
64
  yOffsetMin: number;
65
  /** bottom position of any character in the new mapping table */
66
  yOffsetMax: number;
67
  /** height of the font atlas canvas, power of 2 */
68
  canvasHeight: number;
69
} {
70
  const row = 0;
9✔
71
  // continue from x position of last character in the old mapping
72
  let x = xOffset;
9✔
73
  let yMin = yOffsetMin;
9✔
74
  let yMax = yOffsetMax;
9✔
75

76
  for (const char of characterSet) {
9✔
77
    if (!mapping[char]) {
489✔
78
      // measure texts
79
      const {advance, width, ascent, descent} = measureText(char);
489✔
80
      const height = ascent + descent;
489✔
81

82
      if (x + width + buffer * 2 > maxCanvasWidth) {
489✔
83
        x = 0;
22✔
84
        yMin = yMax;
22✔
85
      }
86
      mapping[char] = {
489✔
87
        x: x + buffer,
88
        y: yMin + buffer,
89
        width,
90
        height,
91
        advance,
92
        anchorX: width / 2,
93
        anchorY: ascent
94
      };
95
      x += width + buffer * 2;
489✔
96
      yMax = Math.max(yMax, yMin + height + buffer * 2);
489✔
97
    }
98
  }
99

100
  return {
9✔
101
    mapping,
102
    xOffset: x,
103
    yOffsetMin: yMin,
104
    yOffsetMax: yMax,
105
    canvasHeight: nextPowOfTwo(yMax)
106
  };
107
}
108

109
function getTextWidth(
110
  text: string[],
111
  startIndex: number,
112
  endIndex: number,
113
  mapping: CharacterMapping
114
): number {
115
  let width = 0;
5✔
116
  for (let i = startIndex; i < endIndex; i++) {
5✔
117
    const character = text[i];
7✔
118
    width += mapping[character]?.advance || 0;
7✔
119
  }
120

121
  return width;
5✔
122
}
123

124
function breakAll(
125
  text: string[],
126
  startIndex: number,
127
  endIndex: number,
128
  maxWidth: number,
129
  iconMapping: CharacterMapping,
130
  target: number[]
131
): number {
UNCOV
132
  let rowStartCharIndex = startIndex;
×
UNCOV
133
  let rowOffsetLeft = 0;
×
134

UNCOV
135
  for (let i = startIndex; i < endIndex; i++) {
×
136
    // 2. figure out where to break lines
UNCOV
137
    const textWidth = getTextWidth(text, i, i + 1, iconMapping);
×
UNCOV
138
    if (rowOffsetLeft + textWidth > maxWidth) {
×
UNCOV
139
      if (rowStartCharIndex < i) {
×
UNCOV
140
        target.push(i);
×
141
      }
UNCOV
142
      rowStartCharIndex = i;
×
UNCOV
143
      rowOffsetLeft = 0;
×
144
    }
UNCOV
145
    rowOffsetLeft += textWidth;
×
146
  }
147

UNCOV
148
  return rowOffsetLeft;
×
149
}
150

151
function breakWord(
152
  text: string[],
153
  startIndex: number,
154
  endIndex: number,
155
  maxWidth: number,
156
  iconMapping: CharacterMapping,
157
  target: number[]
158
): number {
159
  let rowStartCharIndex = startIndex;
1✔
160
  let groupStartCharIndex = startIndex;
1✔
161
  let groupEndCharIndex = startIndex;
1✔
162
  let rowOffsetLeft = 0;
1✔
163

164
  for (let i = startIndex; i < endIndex; i++) {
1✔
165
    // 1. break text into word groups
166
    //  - if current char is white space
167
    //  - else if next char is white space
168
    //  - else if reach last char
169
    if (text[i] === ' ') {
7✔
170
      groupEndCharIndex = i + 1;
2✔
171
    } else if (text[i + 1] === ' ' || i + 1 === endIndex) {
5✔
172
      groupEndCharIndex = i + 1;
3✔
173
    }
174

175
    if (groupEndCharIndex > groupStartCharIndex) {
7✔
176
      // 2. break text into next row at maxWidth
177
      let groupWidth = getTextWidth(text, groupStartCharIndex, groupEndCharIndex, iconMapping);
5✔
178
      if (rowOffsetLeft + groupWidth > maxWidth) {
5✔
179
        if (rowStartCharIndex < groupStartCharIndex) {
2✔
180
          target.push(groupStartCharIndex);
2✔
181
          rowStartCharIndex = groupStartCharIndex;
2✔
182
          rowOffsetLeft = 0;
2✔
183
        }
184

185
        // if a single text group is bigger than maxWidth, then `break-all`
186
        if (groupWidth > maxWidth) {
2✔
UNCOV
187
          groupWidth = breakAll(
×
188
            text,
189
            groupStartCharIndex,
190
            groupEndCharIndex,
191
            maxWidth,
192
            iconMapping,
193
            target
194
          );
195
          // move reference to last row
UNCOV
196
          rowStartCharIndex = target[target.length - 1];
×
197
        }
198
      }
199
      groupStartCharIndex = groupEndCharIndex;
5✔
200
      rowOffsetLeft += groupWidth;
5✔
201
    }
202
  }
203

204
  return rowOffsetLeft;
1✔
205
}
206

207
/**
208
 * Wrap the given text so that each line does not exceed the given max width.
209
 * Returns a list of indices where line breaks should be inserted.
210
 */
211
export function autoWrapping(
212
  text: string[],
213
  wordBreak: 'break-all' | 'break-word',
214
  maxWidth: number,
215
  iconMapping: CharacterMapping,
216
  startIndex: number = 0,
217
  endIndex: number
218
): number[] {
219
  if (endIndex === undefined) {
1✔
UNCOV
220
    endIndex = text.length;
×
221
  }
222
  const result = [];
1✔
223
  if (wordBreak === 'break-all') {
1✔
UNCOV
224
    breakAll(text, startIndex, endIndex, maxWidth, iconMapping, result);
×
225
  } else {
226
    breakWord(text, startIndex, endIndex, maxWidth, iconMapping, result);
1✔
227
  }
228
  return result;
1✔
229
}
230

231
function transformRow(
232
  line: string[],
233
  startIndex: number,
234
  endIndex: number,
235
  iconMapping: CharacterMapping,
236
  leftOffsets: number[],
237
  rowSize: [number, number]
238
) {
239
  let x = 0;
136,130✔
240
  let rowHeight = 0;
136,130✔
241

242
  for (let i = startIndex; i < endIndex; i++) {
136,130✔
243
    const character = line[i];
1,878,987✔
244
    const frame = iconMapping[character];
1,878,987✔
245
    if (frame) {
1,878,987✔
246
      rowHeight = Math.max(rowHeight, frame.height);
1,878,987✔
247
    }
248
  }
249

250
  for (let i = startIndex; i < endIndex; i++) {
136,130✔
251
    const character = line[i];
1,878,987✔
252
    const frame = iconMapping[character];
1,878,987✔
253
    if (frame) {
1,878,987✔
254
      leftOffsets[i] = x + frame.anchorX;
1,878,987✔
255
      x += frame.advance;
1,878,987✔
256
    } else {
257
      log.warn(`Missing character: ${character} (${character.codePointAt(0)})`)();
×
258
      leftOffsets[i] = x;
×
259
      x += MISSING_CHAR_WIDTH;
×
260
    }
261
  }
262

263
  rowSize[0] = x;
136,130✔
264
  rowSize[1] = rowHeight;
136,130✔
265
}
266

267
/**
268
 * Transform a text paragraph to an array of characters, each character contains
269
 */
270
export function transformParagraph(
271
  paragraph: string,
272
  /** font property - distance from baseline to vertical center */
273
  baselineOffset: number,
274
  /** line-height in pixels */
275
  lineHeight: number,
276
  /** CSS word-break option */
277
  wordBreak: 'break-word' | 'break-all',
278
  /** CSS max-width */
279
  maxWidth: number,
280
  /** character mapping table for retrieving a character from font atlas */
281
  iconMapping: CharacterMapping
282
): {
283
  /** x position of each character */
284
  x: number[];
285
  /** y position of each character */
286
  y: number[];
287
  /** the current row width of each character */
288
  rowWidth: number[];
289
  /** the width and height of the paragraph */
290
  size: [number, number];
291
} {
292
  // Break into an array of characters
293
  // When dealing with double-length unicode characters, `str.length` or `str[i]` do not work
294
  const characters = Array.from(paragraph);
136,235✔
295
  const numCharacters = characters.length;
136,235✔
296
  const x = new Array(numCharacters) as number[];
136,235✔
297
  const y = new Array(numCharacters) as number[];
136,235✔
298
  const rowWidth = new Array(numCharacters) as number[];
136,235✔
299
  const autoWrappingEnabled =
136,235✔
300
    (wordBreak === 'break-word' || wordBreak === 'break-all') && isFinite(maxWidth) && maxWidth > 0;
301

302
  // maxWidth and height of the paragraph
303
  const size: [number, number] = [0, 0];
136,235✔
304
  const rowSize: [number, number] = [0, 0];
136,235✔
305
  let rowCount = 0;
136,235✔
306
  let rowOffsetTop = baselineOffset + lineHeight / 2; // this places the top of the first row at 0
136,235✔
307
  let lineStartIndex = 0;
136,235✔
308
  let lineEndIndex = 0;
136,235✔
309

310
  for (let i = 0; i <= numCharacters; i++) {
136,235✔
311
    const char = characters[i];
2,015,225✔
312
    if (char === '\n' || i === numCharacters) {
2,015,225✔
313
      lineEndIndex = i;
136,238✔
314
    }
315

316
    if (lineEndIndex > lineStartIndex) {
2,015,225✔
317
      const rows = autoWrappingEnabled
136,128✔
318
        ? autoWrapping(characters, wordBreak, maxWidth, iconMapping, lineStartIndex, lineEndIndex)
319
        : SINGLE_LINE;
320

321
      for (let rowIndex = 0; rowIndex <= rows.length; rowIndex++) {
136,128✔
322
        const rowStart = rowIndex === 0 ? lineStartIndex : rows[rowIndex - 1];
136,130✔
323
        const rowEnd = rowIndex < rows.length ? rows[rowIndex] : lineEndIndex;
136,130✔
324

325
        transformRow(characters, rowStart, rowEnd, iconMapping, x, rowSize);
136,130✔
326
        for (let j = rowStart; j < rowEnd; j++) {
136,130✔
327
          y[j] = rowOffsetTop;
1,878,987✔
328
          rowWidth[j] = rowSize[0];
1,878,987✔
329
        }
330

331
        rowCount++;
136,130✔
332
        rowOffsetTop += lineHeight;
136,130✔
333
        size[0] = Math.max(size[0], rowSize[0]);
136,130✔
334
      }
335
      lineStartIndex = lineEndIndex;
136,128✔
336
    }
337

338
    if (char === '\n') {
2,015,225✔
339
      // Make sure result.length matches paragraph.length
340
      x[lineStartIndex] = 0;
3✔
341
      y[lineStartIndex] = 0;
3✔
342
      rowWidth[lineStartIndex] = 0;
3✔
343
      lineStartIndex++;
3✔
344
    }
345
  }
346

347
  // last row
348
  size[1] = rowCount * lineHeight;
136,235✔
349
  return {x, y, rowWidth, size};
136,235✔
350
}
351

352
export function getTextFromBuffer({
353
  value,
354
  length,
355
  stride,
356
  offset,
357
  startIndices,
358
  characterSet
359
}: {
360
  value: Uint8Array | Uint8ClampedArray | Uint16Array | Uint32Array;
361
  length: number;
362
  stride?: number;
363
  offset?: number;
364
  startIndices: NumericArray;
365
  characterSet?: Set<string>;
366
}): {
367
  texts: string[];
368
  characterCount: number;
369
} {
370
  const bytesPerElement = value.BYTES_PER_ELEMENT;
6✔
371
  const elementStride = stride ? stride / bytesPerElement : 1;
6✔
372
  const elementOffset = offset ? offset / bytesPerElement : 0;
6✔
373
  const characterCount =
374
    startIndices[length] || Math.ceil((value.length - elementOffset) / elementStride);
6✔
375
  const autoCharacterSet = characterSet && new Set<number>();
6✔
376

377
  const texts = new Array(length);
6✔
378

379
  let codes = value;
6✔
380
  if (elementStride > 1 || elementOffset > 0) {
6✔
381
    const ArrayType = value.constructor as
3✔
382
      | Uint8ArrayConstructor
383
      | Uint8ClampedArrayConstructor
384
      | Uint16ArrayConstructor
385
      | Uint32ArrayConstructor;
386
    codes = new ArrayType(characterCount);
3✔
387
    for (let i = 0; i < characterCount; i++) {
3✔
388
      codes[i] = value[i * elementStride + elementOffset];
23✔
389
    }
390
  }
391

392
  for (let index = 0; index < length; index++) {
6✔
393
    const startIndex = startIndices[index];
12✔
394
    const endIndex = startIndices[index + 1] || characterCount;
12✔
395
    const codesAtIndex = codes.subarray(startIndex, endIndex);
12✔
396
    // @ts-ignore TS wants the argument to be number[] but typed array works too
397
    texts[index] = String.fromCodePoint.apply(null, codesAtIndex);
12✔
398
    if (autoCharacterSet) {
12✔
399
      // eslint-disable-next-line @typescript-eslint/unbound-method
400
      codesAtIndex.forEach(autoCharacterSet.add, autoCharacterSet);
2✔
401
    }
402
  }
403

404
  if (autoCharacterSet) {
6✔
405
    for (const charCode of autoCharacterSet) {
1✔
406
      characterSet.add(String.fromCodePoint(charCode));
4✔
407
    }
408
  }
409

410
  return {texts, characterCount};
6✔
411
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc