• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In
Build has been canceled!

excaliburjs / Excalibur / 14840900305

05 May 2025 04:07PM UTC coverage: 87.864% (-1.5%) from 89.319%
14840900305

push

github

web-flow
chore: migrate tests to vitest (#3381)

managed to migrate the engine leak & memory reporters, although it took a bit of rigamarole. Mainly because, as opposed to karma, the reporter runs in node and not the browser. So I have to track the data needed separately in some global hooks _within_ the browser environment, which the reporter then reads and creates the logs if needed.

However, it seems the memory tracking is regularly reporting >1 mb, so I'm not sure if it's tracking properly or if things have changed with the new browsers. My only theory is that because the timing where memory is read is during an `afterEach` _before_ the test's `afterEach`, it's running before any potential cleanup. The timing of this is not something that's easy to control, unfortunately. I did try to prove this theory by doing the memory analysis on the next test's `beforeEach`, but it didnt seem to change the results, so it may not be an issue.

Implementation is done in `src/spec/vitest/__reporters__/memory.ts` and `src/spec/vitest/__reporters/memory.setup.ts`

---

I noticed CouroutineSpec was an offender for >10mb memory, which spins up additional engines. I added a short 100ms wait after the engine.dispose and the memory usage did drop, so it does seem like this is prone to scheduled garbage collecting.

---

I was able to run the garbage collector (if exposed, currently only on chrome) and this makes the reports more accurate

4998 of 6942 branches covered (72.0%)

13655 of 15541 relevant lines covered (87.86%)

25165.01 hits per line

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

30.99
/src/engine/Graphics/FontTextInstance.ts
1
import { BoundingBox } from '../Collision/BoundingBox';
2
import { Color } from '../Color';
3
import { line } from '../Util/DrawUtil';
4
import { ExcaliburGraphicsContextWebGL } from './Context/ExcaliburGraphicsContextWebGL';
5
import type { ExcaliburGraphicsContext } from './Context/ExcaliburGraphicsContext';
6
import type { Font } from './Font';
7

8
export class FontTextInstance {
9
  public canvas: HTMLCanvasElement;
10
  public ctx: CanvasRenderingContext2D;
11
  private _textFragments: { x: number; y: number; canvas: HTMLCanvasElement }[] = [];
9✔
12
  public dimensions: BoundingBox;
13
  public disposed: boolean = false;
9✔
14
  private _lastHashCode: string;
15

16
  constructor(
17
    public readonly font: Font,
9✔
18
    public readonly text: string,
9✔
19
    public readonly color: Color,
9✔
20
    public readonly maxWidth?: number
9✔
21
  ) {
22
    this.canvas = document.createElement('canvas');
9✔
23
    const ctx = this.canvas.getContext('2d');
9✔
24
    if (!ctx) {
9!
25
      throw new Error('Unable to create FontTextInstance, internal canvas failed to create');
×
26
    }
27
    this.ctx = ctx;
9✔
28
    this.dimensions = this.measureText(text);
9✔
29
    this._setDimension(this.dimensions, this.ctx);
9✔
30
    this._lastHashCode = this.getHashCode();
9✔
31
  }
32

33
  measureText(text: string, maxWidth?: number): BoundingBox {
34
    if (this.disposed) {
12!
35
      throw Error('Accessing disposed text instance! ' + this.text);
×
36
    }
37
    let lines = null;
12✔
38
    if (maxWidth != null) {
12!
39
      lines = this._getLinesFromText(text, maxWidth);
×
40
    } else {
41
      lines = text.split('\n');
12✔
42
    }
43

44
    const maxWidthLine = lines.reduce((a, b) => {
12✔
45
      return a.length > b.length ? a : b;
×
46
    });
47

48
    this._applyFont(this.ctx); // font must be applied to the context to measure it
12✔
49
    const metrics = this.ctx.measureText(maxWidthLine);
12✔
50
    let textHeight = Math.abs(metrics.actualBoundingBoxAscent) + Math.abs(metrics.actualBoundingBoxDescent);
12✔
51

52
    // TODO line height makes the text bounds wonky
53
    const lineAdjustedHeight = textHeight * lines.length;
12✔
54
    textHeight = lineAdjustedHeight;
12✔
55
    const bottomBounds = lineAdjustedHeight - Math.abs(metrics.actualBoundingBoxAscent);
12✔
56
    const x = 0;
12✔
57
    const y = 0;
12✔
58
    const measurement = new BoundingBox({
12✔
59
      left: x - Math.abs(metrics.actualBoundingBoxLeft) - this.font.padding,
60
      top: y - Math.abs(metrics.actualBoundingBoxAscent) - this.font.padding,
61
      bottom: y + bottomBounds + this.font.padding,
62
      right: x + Math.abs(metrics.actualBoundingBoxRight) + this.font.padding
63
    });
64

65
    return measurement;
12✔
66
  }
67

68
  private _setDimension(textBounds: BoundingBox, bitmap: CanvasRenderingContext2D) {
69
    let lineHeightRatio = 1;
9✔
70
    if (this.font.lineHeight) {
9!
71
      lineHeightRatio = this.font.lineHeight / this.font.size;
×
72
    }
73
    // Changing the width and height clears the context properties
74
    // We double the bitmap width to account for all possible alignment
75
    // We scale by "quality" so we render text without jaggies
76
    bitmap.canvas.width = (textBounds.width + this.font.padding * 2) * 2 * this.font.quality;
9✔
77
    bitmap.canvas.height = (textBounds.height + this.font.padding * 2) * 2 * this.font.quality * lineHeightRatio;
9✔
78
  }
79

80
  public static getHashCode(font: Font, text: string, color?: Color) {
81
    const hash =
82
      text +
27✔
83
      '__hashcode__' +
84
      font.fontString +
85
      font.showDebug +
86
      font.textAlign +
87
      font.baseAlign +
88
      font.direction +
89
      font.lineHeight +
90
      JSON.stringify(font.shadow) +
91
      (font.padding.toString() +
92
        font.smoothing.toString() +
93
        font.lineWidth.toString() +
94
        font.lineDash.toString() +
27!
95
        font.strokeColor?.toString() +
96
        (color ? color.toString() : font.color.toString()));
27✔
97
    return hash;
27✔
98
  }
99

100
  getHashCode(includeColor: boolean = true) {
9✔
101
    return FontTextInstance.getHashCode(this.font, this.text, includeColor ? this.color : undefined);
9!
102
  }
103

104
  protected _applyRasterProperties(ctx: CanvasRenderingContext2D) {
105
    ctx.translate(this.font.padding, this.font.padding);
×
106
    ctx.imageSmoothingEnabled = this.font.smoothing;
×
107
    ctx.lineWidth = this.font.lineWidth;
×
108
    ctx.setLineDash(this.font.lineDash ?? ctx.getLineDash());
×
109
    ctx.strokeStyle = this.font.strokeColor?.toString() ?? '';
×
110
    ctx.fillStyle = this.color.toString();
×
111
  }
112

113
  private _applyFont(ctx: CanvasRenderingContext2D) {
114
    ctx.resetTransform();
12✔
115
    ctx.translate(this.font.padding + ctx.canvas.width / 2, this.font.padding + ctx.canvas.height / 2);
12✔
116
    ctx.scale(this.font.quality, this.font.quality);
12✔
117
    ctx.textAlign = this.font.textAlign;
12✔
118
    ctx.textBaseline = this.font.baseAlign;
12✔
119
    ctx.font = this.font.fontString;
12✔
120
    ctx.direction = this.font.direction;
12✔
121

122
    if (this.font.shadow) {
12!
123
      if (this.font.shadow.color) {
×
124
        ctx.shadowColor = this.font.shadow.color.toString();
×
125
      }
126
      if (this.font.shadow.blur) {
×
127
        ctx.shadowBlur = this.font.shadow.blur;
×
128
      }
129
      if (this.font.shadow.offset) {
×
130
        ctx.shadowOffsetX = this.font.shadow.offset.x;
×
131
        ctx.shadowOffsetY = this.font.shadow.offset.y;
×
132
      }
133
    }
134
  }
135

136
  private _drawText(ctx: CanvasRenderingContext2D, lines: string[], lineHeight: number): void {
137
    this._applyRasterProperties(ctx);
×
138
    this._applyFont(ctx);
×
139

140
    for (let i = 0; i < lines.length; i++) {
×
141
      const line = lines[i];
×
142
      if (this.color) {
×
143
        ctx.fillText(line, 0, i * lineHeight);
×
144
      }
145

146
      if (this.font.strokeColor) {
×
147
        ctx.strokeText(line, 0, i * lineHeight);
×
148
      }
149
    }
150

151
    if (this.font.showDebug) {
×
152
      // Horizontal line
153
      /* istanbul ignore next */
154
      line(ctx, Color.Green, -ctx.canvas.width / 2, 0, ctx.canvas.width / 2, 0, 2);
×
155
      // Vertical line
156
      /* istanbul ignore next */
157
      line(ctx, Color.Red, 0, -ctx.canvas.height / 2, 0, ctx.canvas.height / 2, 2);
×
158
    }
159
  }
160

161
  private _splitTextBitmap(bitmap: CanvasRenderingContext2D) {
162
    const textImages: { x: number; y: number; canvas: HTMLCanvasElement }[] = [];
×
163
    let currentX = 0;
×
164
    let currentY = 0;
×
165
    // 4k is the max for mobile devices
166
    const width = Math.min(4096, bitmap.canvas.width);
×
167
    const height = Math.min(4096, bitmap.canvas.height);
×
168

169
    // Splits the original bitmap into 4k max chunks
170
    while (currentX < bitmap.canvas.width) {
×
171
      while (currentY < bitmap.canvas.height) {
×
172
        // create new bitmap
173
        const canvas = document.createElement('canvas');
×
174
        canvas.width = width;
×
175
        canvas.height = height;
×
176
        const ctx = canvas.getContext('2d');
×
177
        if (!ctx) {
×
178
          throw new Error('Unable to split internal FontTextInstance bitmap, failed to create internal canvas');
×
179
        }
180

181
        // draw current slice to new bitmap in < 4k chunks
182
        ctx.drawImage(bitmap.canvas, currentX, currentY, width, height, 0, 0, width, height);
×
183

184
        textImages.push({ x: currentX, y: currentY, canvas });
×
185
        currentY += height;
×
186
      }
187
      currentX += width;
×
188
      currentY = 0;
×
189
    }
190
    return textImages;
×
191
  }
192

193
  public flagDirty() {
194
    this._dirty = true;
×
195
  }
196
  private _dirty = true;
9✔
197
  private _ex?: ExcaliburGraphicsContext;
198
  public render(ex: ExcaliburGraphicsContext, x: number, y: number, maxWidth?: number) {
199
    if (this.disposed) {
×
200
      throw Error('Accessing disposed text instance! ' + this.text);
×
201
    }
202
    this._ex = ex;
×
203
    const hashCode = this.getHashCode();
×
204
    if (this._lastHashCode !== hashCode) {
×
205
      this._dirty = true;
×
206
    }
207

208
    // Calculate image chunks
209
    if (this._dirty) {
×
210
      this.dimensions = this.measureText(this.text, maxWidth);
×
211
      this._setDimension(this.dimensions, this.ctx);
×
212
      const lines = this._getLinesFromText(this.text, maxWidth);
×
213
      const lineHeight = this.font.lineHeight ?? this.dimensions.height / lines.length;
×
214

215
      // draws the text to the main bitmap
216
      this._drawText(this.ctx, lines, lineHeight);
×
217

218
      // clear any out old fragments
219
      if (ex instanceof ExcaliburGraphicsContextWebGL) {
×
220
        for (const frag of this._textFragments) {
×
221
          ex.textureLoader.delete(frag.canvas);
×
222
        }
223
      }
224

225
      // splits to < 4k fragments for large text
226
      this._textFragments = this._splitTextBitmap(this.ctx);
×
227

228
      if (ex instanceof ExcaliburGraphicsContextWebGL) {
×
229
        for (const frag of this._textFragments) {
×
230
          ex.textureLoader.load(frag.canvas, { filtering: this.font.filtering }, true);
×
231
        }
232
      }
233
      this._lastHashCode = hashCode;
×
234
      this._dirty = false;
×
235
    }
236

237
    // draws the bitmap fragments to excalibur graphics context
238
    for (const frag of this._textFragments) {
×
239
      ex.drawImage(
×
240
        frag.canvas,
241
        0,
242
        0,
243
        frag.canvas.width,
244
        frag.canvas.height,
245
        frag.x / this.font.quality + x - this.ctx.canvas.width / this.font.quality / 2,
246
        frag.y / this.font.quality + y - this.ctx.canvas.height / this.font.quality / 2,
247
        frag.canvas.width / this.font.quality,
248
        frag.canvas.height / this.font.quality
249
      );
250
    }
251
  }
252

253
  dispose() {
254
    this.disposed = true;
×
255
    this.dimensions = undefined as any;
×
256
    this.canvas = undefined as any;
×
257
    this.ctx = undefined as any;
×
258
    if (this._ex instanceof ExcaliburGraphicsContextWebGL) {
×
259
      for (const frag of this._textFragments) {
×
260
        this._ex.textureLoader.delete(frag.canvas);
×
261
      }
262
    }
263
    this._textFragments.length = 0;
×
264
  }
265

266
  /**
267
   * Return array of lines split based on the \n character, and the maxWidth? constraint
268
   * @param text
269
   * @param maxWidth
270
   */
271
  private _cachedText?: string;
272
  private _cachedLines?: string[];
273
  private _cachedRenderWidth?: number;
274
  private _getLinesFromText(text: string, maxWidth?: number): string[] {
275
    if (this._cachedText === text && this._cachedRenderWidth === maxWidth && this._cachedLines?.length) {
×
276
      return this._cachedLines;
×
277
    }
278

279
    const lines = text.split('\n');
×
280

281
    if (maxWidth == null) {
×
282
      return lines;
×
283
    }
284

285
    // If the current line goes past the maxWidth, append a new line without modifying the underlying text.
286
    for (let i = 0; i < lines.length; i++) {
×
287
      let line = lines[i];
×
288
      let newLine = '';
×
289
      if (this.measureText(line).width > maxWidth) {
×
290
        while (this.measureText(line).width > maxWidth) {
×
291
          newLine = line[line.length - 1] + newLine;
×
292
          line = line.slice(0, -1); // Remove last character from line
×
293
        }
294

295
        // Update the array with our new values
296
        lines[i] = line;
×
297
        lines[i + 1] = newLine;
×
298
      }
299
    }
300

301
    this._cachedText = text;
×
302
    this._cachedLines = lines;
×
303
    this._cachedRenderWidth = maxWidth;
×
304

305
    return lines;
×
306
  }
307
}
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