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

excaliburjs / Excalibur / 11647275945

03 Nov 2024 01:14AM UTC coverage: 90.198% (-0.2%) from 90.374%
11647275945

push

github

eonarheim
docs: fix version header

5861 of 7457 branches covered (78.6%)

12837 of 14232 relevant lines covered (90.2%)

25251.21 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,056✔
12
  public dimensions: BoundingBox;
13
  public disposed: boolean = false;
1,056✔
14
  private _lastHashCode: string;
15

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

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

44
    const maxWidthLine = lines.reduce((a, b) => {
2,224✔
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,224✔
49
    const metrics = this.ctx.measureText(maxWidthLine);
2,224✔
50
    let textHeight = Math.abs(metrics.actualBoundingBoxAscent) + Math.abs(metrics.actualBoundingBoxDescent);
2,224✔
51

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

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

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

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

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

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

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

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

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

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

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

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

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

193
  public flagDirty() {
194
    this._dirty = true;
×
195
  }
196
  private _dirty = true;
1,056✔
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);
21✔
211
      this._setDimension(this.dimensions, this.ctx);
21✔
212
      const lines = this._getLinesFromText(this.text, maxWidth);
21✔
213
      const lineHeight = this.font.lineHeight ?? this.dimensions.height / lines.length;
21✔
214

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

218
      // clear any out old fragments
219
      if (ex instanceof ExcaliburGraphicsContextWebGL) {
21✔
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);
21✔
227

228
      if (ex instanceof ExcaliburGraphicsContextWebGL) {
21✔
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;
21✔
234
      this._dirty = false;
21✔
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;
16✔
255
    this.dimensions = undefined as any;
16✔
256
    this.canvas = undefined as any;
16✔
257
    this.ctx = undefined as any;
16✔
258
    if (this._ex instanceof ExcaliburGraphicsContextWebGL) {
16!
259
      for (const frag of this._textFragments) {
×
260
        this._ex.textureLoader.delete(frag.canvas);
×
261
      }
262
    }
263
    this._textFragments.length = 0;
16✔
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) {
22!
276
      return this._cachedLines;
1✔
277
    }
278

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

281
    if (maxWidth == null) {
21✔
282
      return lines;
20✔
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