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

excaliburjs / Excalibur / 13161186223

05 Feb 2025 03:47PM UTC coverage: 90.01% (-0.2%) from 90.166%
13161186223

push

github

eonarheim
fix: TriggerOptions type is optional

6301 of 8104 branches covered (77.75%)

13803 of 15335 relevant lines covered (90.01%)

25558.63 hits per line

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

92.86
/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 { ExcaliburGraphicsContext } from './Context/ExcaliburGraphicsContext';
6
import { 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 }[] = [];
1,054✔
12
  public dimensions: BoundingBox;
13
  public disposed: boolean = false;
1,054✔
14
  private _lastHashCode: string;
15

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

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

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

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

52
    // TODO line height makes the text bounds wonky
53
    const lineAdjustedHeight = textHeight * lines.length;
2,217✔
54
    textHeight = lineAdjustedHeight;
2,217✔
55
    const bottomBounds = lineAdjustedHeight - Math.abs(metrics.actualBoundingBoxAscent);
2,217✔
56
    const x = 0;
2,217✔
57
    const y = 0;
2,217✔
58
    const measurement = new BoundingBox({
2,217✔
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;
2,217✔
66
  }
67

68
  private _setDimension(textBounds: BoundingBox, bitmap: CanvasRenderingContext2D) {
69
    let lineHeightRatio = 1;
1,073✔
70
    if (this.font.lineHeight) {
1,073✔
71
      lineHeightRatio = this.font.lineHeight / this.font.size;
2✔
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;
1,073✔
77
    bitmap.canvas.height = (textBounds.height + this.font.padding * 2) * 2 * this.font.quality * lineHeightRatio;
1,073✔
78
  }
79

80
  public static getHashCode(font: Font, text: string, color?: Color) {
81
    const hash =
82
      text +
2,184✔
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() +
95
        font.strokeColor?.toString() +
6,552!
96
        (color ? color.toString() : font.color.toString()));
2,184✔
97
    return hash;
2,184✔
98
  }
99

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

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

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

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

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

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

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

151
    if (this.font.showDebug) {
19!
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 }[] = [];
19✔
163
    let currentX = 0;
19✔
164
    let currentY = 0;
19✔
165
    // 4k is the max for mobile devices
166
    const width = Math.min(4096, bitmap.canvas.width);
19✔
167
    const height = Math.min(4096, bitmap.canvas.height);
19✔
168

169
    // Splits the original bitmap into 4k max chunks
170
    while (currentX < bitmap.canvas.width) {
19✔
171
      while (currentY < bitmap.canvas.height) {
21✔
172
        // create new bitmap
173
        const canvas = document.createElement('canvas');
24✔
174
        canvas.width = width;
24✔
175
        canvas.height = height;
24✔
176
        const ctx = canvas.getContext('2d');
24✔
177
        if (!ctx) {
24!
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);
24✔
183

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

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

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

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

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

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

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

237
    // draws the bitmap fragments to excalibur graphics context
238
    for (const frag of this._textFragments) {
25✔
239
      ex.drawImage(
30✔
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;
10✔
255
    this.dimensions = undefined as any;
10✔
256
    this.canvas = undefined as any;
10✔
257
    this.ctx = undefined as any;
10✔
258
    if (this._ex instanceof ExcaliburGraphicsContextWebGL) {
10!
259
      for (const frag of this._textFragments) {
×
260
        this._ex.textureLoader.delete(frag.canvas);
×
261
      }
262
    }
263
    this._textFragments.length = 0;
10✔
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) {
20!
276
      return this._cachedLines;
1✔
277
    }
278

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

281
    if (maxWidth == null) {
19✔
282
      return lines;
18✔
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++) {
1✔
287
      let line = lines[i];
5✔
288
      let newLine = '';
5✔
289
      if (this.measureText(line).width > maxWidth) {
5✔
290
        while (this.measureText(line).width > maxWidth) {
4✔
291
          newLine = line[line.length - 1] + newLine;
109✔
292
          line = line.slice(0, -1); // Remove last character from line
109✔
293
        }
294

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

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

305
    return lines;
1✔
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