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

visgl / luma.gl / 25879749619

14 May 2026 07:05PM UTC coverage: 74.881% (-0.2%) from 75.089%
25879749619

push

github

web-flow
feat(text) TextArrowModel (#2615)

6380 of 9600 branches covered (66.46%)

Branch coverage included in aggregate %.

462 of 672 new or added lines in 6 files covered. (68.75%)

123 existing lines in 9 files now uncovered.

13975 of 17583 relevant lines covered (79.48%)

782.31 hits per line

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

55.66
/modules/text/src/text-2d/text-utils.ts
1
// luma.gl
2
// SPDX-License-Identifier: MIT
3
// Copyright (c) vis.gl contributors
4
// Adapted from deck.gl TextLayer utilities under the MIT License.
5

6
import {log} from '@luma.gl/core';
7
import type {NumericArray} from '@math.gl/core';
8

9
const MISSING_CHAR_WIDTH = 32;
4✔
10
const SINGLE_LINE: number[] = [];
4✔
11

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

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

24
export function nextPowOfTwo(number: number): number {
25
  return number === 0 ? 0 : Math.pow(2, Math.ceil(Math.log2(number)));
2!
26
}
27

28
/** Generate or extend a character mapping table for a font atlas. */
29
export function buildMapping({
30
  characterSet,
31
  measureText,
32
  buffer,
33
  maxCanvasWidth,
34
  mapping = {},
1✔
35
  xOffset = 0,
1✔
36
  yOffsetMin = 0,
1✔
37
  yOffsetMax = 0
1✔
38
}: {
39
  characterSet: Set<string>;
40
  measureText: (character: string) => {
41
    advance: number;
42
    width: number;
43
    ascent: number;
44
    descent: number;
45
  };
46
  buffer: number;
47
  maxCanvasWidth: number;
48
  mapping?: CharacterMapping;
49
  xOffset?: number;
50
  yOffsetMin?: number;
51
  yOffsetMax?: number;
52
}): {
53
  mapping: CharacterMapping;
54
  xOffset: number;
55
  yOffsetMin: number;
56
  yOffsetMax: number;
57
  canvasHeight: number;
58
} {
59
  let x = xOffset;
1✔
60
  let yMin = yOffsetMin;
1✔
61
  let yMax = yOffsetMax;
1✔
62

63
  for (const character of characterSet) {
1✔
64
    if (mapping[character]) {
4!
NEW
65
      continue;
×
66
    }
67
    const {advance, width, ascent, descent} = measureText(character);
4✔
68
    const height = ascent + descent;
4✔
69

70
    if (x + width + buffer * 2 > maxCanvasWidth) {
4✔
71
      x = 0;
1✔
72
      yMin = yMax;
1✔
73
    }
74
    mapping[character] = {
4✔
75
      x: x + buffer,
76
      y: yMin + buffer,
77
      width,
78
      height,
79
      advance,
80
      anchorX: width / 2,
81
      anchorY: ascent
82
    };
83
    x += width + buffer * 2;
4✔
84
    yMax = Math.max(yMax, yMin + height + buffer * 2);
4✔
85
  }
86

87
  return {
1✔
88
    mapping,
89
    xOffset: x,
90
    yOffsetMin: yMin,
91
    yOffsetMax: yMax,
92
    canvasHeight: nextPowOfTwo(yMax)
93
  };
94
}
95

96
function getTextWidth(
97
  text: string[],
98
  startIndex: number,
99
  endIndex: number,
100
  mapping: CharacterMapping
101
): number {
NEW
102
  let width = 0;
×
NEW
103
  for (let index = startIndex; index < endIndex; index++) {
×
NEW
104
    width += mapping[text[index]]?.advance || 0;
×
105
  }
NEW
106
  return width;
×
107
}
108

109
function breakAll(
110
  text: string[],
111
  startIndex: number,
112
  endIndex: number,
113
  maxWidth: number,
114
  mapping: CharacterMapping,
115
  target: number[]
116
): number {
NEW
117
  let rowStartCharacterIndex = startIndex;
×
NEW
118
  let rowOffsetLeft = 0;
×
119

NEW
120
  for (let index = startIndex; index < endIndex; index++) {
×
NEW
121
    const width = getTextWidth(text, index, index + 1, mapping);
×
NEW
122
    if (rowOffsetLeft + width > maxWidth) {
×
NEW
123
      if (rowStartCharacterIndex < index) {
×
NEW
124
        target.push(index);
×
125
      }
NEW
126
      rowStartCharacterIndex = index;
×
NEW
127
      rowOffsetLeft = 0;
×
128
    }
NEW
129
    rowOffsetLeft += width;
×
130
  }
131

NEW
132
  return rowOffsetLeft;
×
133
}
134

135
function breakWord(
136
  text: string[],
137
  startIndex: number,
138
  endIndex: number,
139
  maxWidth: number,
140
  mapping: CharacterMapping,
141
  target: number[]
142
): number {
NEW
143
  let rowStartCharacterIndex = startIndex;
×
NEW
144
  let groupStartCharacterIndex = startIndex;
×
NEW
145
  let groupEndCharacterIndex = startIndex;
×
NEW
146
  let rowOffsetLeft = 0;
×
147

NEW
148
  for (let index = startIndex; index < endIndex; index++) {
×
NEW
149
    if (text[index] === ' ') {
×
NEW
150
      groupEndCharacterIndex = index + 1;
×
NEW
151
    } else if (text[index + 1] === ' ' || index + 1 === endIndex) {
×
NEW
152
      groupEndCharacterIndex = index + 1;
×
153
    }
154

NEW
155
    if (groupEndCharacterIndex > groupStartCharacterIndex) {
×
NEW
156
      let groupWidth = getTextWidth(
×
157
        text,
158
        groupStartCharacterIndex,
159
        groupEndCharacterIndex,
160
        mapping
161
      );
NEW
162
      if (rowOffsetLeft + groupWidth > maxWidth) {
×
NEW
163
        if (rowStartCharacterIndex < groupStartCharacterIndex) {
×
NEW
164
          target.push(groupStartCharacterIndex);
×
NEW
165
          rowStartCharacterIndex = groupStartCharacterIndex;
×
NEW
166
          rowOffsetLeft = 0;
×
167
        }
NEW
168
        if (groupWidth > maxWidth) {
×
NEW
169
          groupWidth = breakAll(
×
170
            text,
171
            groupStartCharacterIndex,
172
            groupEndCharacterIndex,
173
            maxWidth,
174
            mapping,
175
            target
176
          );
NEW
177
          rowStartCharacterIndex = target[target.length - 1] ?? rowStartCharacterIndex;
×
178
        }
179
      }
NEW
180
      groupStartCharacterIndex = groupEndCharacterIndex;
×
NEW
181
      rowOffsetLeft += groupWidth;
×
182
    }
183
  }
184

NEW
185
  return rowOffsetLeft;
×
186
}
187

188
/** Return indices where automatic line breaks should be inserted. */
189
export function autoWrapping(
190
  text: string[],
191
  wordBreak: 'break-word' | 'break-all',
192
  maxWidth: number,
193
  mapping: CharacterMapping,
194
  startIndex = 0,
×
195
  endIndex: number = text.length
×
196
): number[] {
NEW
197
  const result: number[] = [];
×
NEW
198
  if (wordBreak === 'break-all') {
×
NEW
199
    breakAll(text, startIndex, endIndex, maxWidth, mapping, result);
×
200
  } else {
NEW
201
    breakWord(text, startIndex, endIndex, maxWidth, mapping, result);
×
202
  }
NEW
203
  return result;
×
204
}
205

206
function transformRow(
207
  line: string[],
208
  startIndex: number,
209
  endIndex: number,
210
  mapping: CharacterMapping,
211
  leftOffsets: number[],
212
  rowSize: [number, number]
213
): void {
214
  let x = 0;
2✔
215
  let rowHeight = 0;
2✔
216

217
  for (let index = startIndex; index < endIndex; index++) {
2✔
218
    const frame = mapping[line[index]];
3✔
219
    if (frame) {
3!
220
      rowHeight = Math.max(rowHeight, frame.height);
3✔
221
    }
222
  }
223

224
  for (let index = startIndex; index < endIndex; index++) {
2✔
225
    const character = line[index];
3✔
226
    const frame = mapping[character];
3✔
227
    if (frame) {
3!
228
      leftOffsets[index] = x + frame.anchorX;
3✔
229
      x += frame.advance;
3✔
230
    } else {
NEW
231
      log.warn(`Missing character: ${character} (${character.codePointAt(0)})`)();
×
NEW
232
      leftOffsets[index] = x;
×
NEW
233
      x += MISSING_CHAR_WIDTH;
×
234
    }
235
  }
236

237
  rowSize[0] = x;
2✔
238
  rowSize[1] = rowHeight;
2✔
239
}
240

241
/** Transform a paragraph into per-character x/y offsets and measured block size. */
242
export function transformParagraph(
243
  paragraph: string,
244
  baselineOffset: number,
245
  lineHeight: number,
246
  wordBreak: 'break-word' | 'break-all' | null,
247
  maxWidth: number | null,
248
  mapping: CharacterMapping
249
): {
250
  x: number[];
251
  y: number[];
252
  rowWidth: number[];
253
  size: [number, number];
254
} {
255
  const characters = Array.from(paragraph);
1✔
256
  const numberOfCharacters = characters.length;
1✔
257
  const x = new Array(numberOfCharacters) as number[];
1✔
258
  const y = new Array(numberOfCharacters) as number[];
1✔
259
  const rowWidth = new Array(numberOfCharacters) as number[];
1✔
260
  const autoWrappingEnabled =
1!
261
    (wordBreak === 'break-word' || wordBreak === 'break-all') &&
262
    Number.isFinite(maxWidth) &&
263
    Number(maxWidth) > 0;
264

265
  const size: [number, number] = [0, 0];
1✔
266
  const rowSize: [number, number] = [0, 0];
1✔
267
  let rowCount = 0;
1✔
268
  let rowOffsetTop = baselineOffset + lineHeight / 2;
1✔
269
  let lineStartIndex = 0;
1✔
270
  let lineEndIndex = 0;
1✔
271

272
  for (let index = 0; index <= numberOfCharacters; index++) {
1✔
273
    const character = characters[index];
5✔
274
    if (character === '\n' || index === numberOfCharacters) {
5✔
275
      lineEndIndex = index;
2✔
276
    }
277

278
    if (lineEndIndex > lineStartIndex) {
5✔
279
      const rows = autoWrappingEnabled
2!
280
        ? autoWrapping(
281
            characters,
282
            wordBreak!,
283
            Number(maxWidth),
284
            mapping,
285
            lineStartIndex,
286
            lineEndIndex
287
          )
288
        : SINGLE_LINE;
289

290
      for (let rowIndex = 0; rowIndex <= rows.length; rowIndex++) {
2✔
291
        const rowStart = rowIndex === 0 ? lineStartIndex : rows[rowIndex - 1];
2!
292
        const rowEnd = rowIndex < rows.length ? rows[rowIndex] : lineEndIndex;
2!
293
        transformRow(characters, rowStart, rowEnd, mapping, x, rowSize);
2✔
294
        for (let characterIndex = rowStart; characterIndex < rowEnd; characterIndex++) {
2✔
295
          y[characterIndex] = rowOffsetTop;
3✔
296
          rowWidth[characterIndex] = rowSize[0];
3✔
297
        }
298
        rowCount++;
2✔
299
        rowOffsetTop += lineHeight;
2✔
300
        size[0] = Math.max(size[0], rowSize[0]);
2✔
301
      }
302
      lineStartIndex = lineEndIndex;
2✔
303
    }
304

305
    if (character === '\n') {
5✔
306
      x[lineStartIndex] = 0;
1✔
307
      y[lineStartIndex] = 0;
1✔
308
      rowWidth[lineStartIndex] = 0;
1✔
309
      lineStartIndex++;
1✔
310
    }
311
  }
312

313
  size[1] = rowCount * lineHeight;
1✔
314
  return {x, y, rowWidth, size};
1✔
315
}
316

317
/** Convert code point buffers into strings using deck.gl's binary text contract. */
318
export function getTextFromBuffer({
319
  value,
320
  length,
321
  stride,
322
  offset,
323
  startIndices,
324
  characterSet
325
}: {
326
  value: Uint8Array | Uint8ClampedArray | Uint16Array | Uint32Array;
327
  length: number;
328
  stride?: number;
329
  offset?: number;
330
  startIndices: NumericArray;
331
  characterSet?: Set<string>;
332
}): {
333
  texts: string[];
334
  characterCount: number;
335
} {
336
  const bytesPerElement = value.BYTES_PER_ELEMENT;
1✔
337
  const elementStride = stride ? stride / bytesPerElement : 1;
1!
338
  const elementOffset = offset ? offset / bytesPerElement : 0;
1!
339
  const characterCount =
340
    startIndices[length] || Math.ceil((value.length - elementOffset) / elementStride);
1✔
341
  const autoCharacterSet = characterSet && new Set<number>();
1!
342
  const texts = new Array(length);
1✔
343

344
  let codes = value;
1✔
345
  if (elementStride > 1 || elementOffset > 0) {
1!
NEW
346
    const ArrayType = value.constructor as
×
347
      | Uint8ArrayConstructor
348
      | Uint8ClampedArrayConstructor
349
      | Uint16ArrayConstructor
350
      | Uint32ArrayConstructor;
NEW
351
    codes = new ArrayType(characterCount);
×
NEW
352
    for (let index = 0; index < characterCount; index++) {
×
NEW
353
      codes[index] = value[index * elementStride + elementOffset];
×
354
    }
355
  }
356

357
  for (let rowIndex = 0; rowIndex < length; rowIndex++) {
1✔
358
    const startIndex = startIndices[rowIndex];
2✔
359
    const endIndex = startIndices[rowIndex + 1] || characterCount;
2✔
360
    const codesAtIndex = codes.subarray(startIndex, endIndex);
2✔
361
    // @ts-expect-error Typed arrays are accepted by String.fromCodePoint at runtime.
362
    texts[rowIndex] = String.fromCodePoint.apply(null, codesAtIndex);
2✔
363
    if (autoCharacterSet) {
2!
364
      // eslint-disable-next-line @typescript-eslint/unbound-method
NEW
365
      codesAtIndex.forEach(autoCharacterSet.add, autoCharacterSet);
×
366
    }
367
  }
368

369
  if (autoCharacterSet) {
1!
NEW
370
    for (const characterCode of autoCharacterSet) {
×
NEW
371
      characterSet.add(String.fromCodePoint(characterCode));
×
372
    }
373
  }
374

375
  return {texts, characterCount};
1✔
376
}
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