Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/editor/browser/gpu/raster/glyphRasterizer.ts
5281 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { memoize } from '../../../../base/common/decorators.js';
7
import { Disposable } from '../../../../base/common/lifecycle.js';
8
import { isMacintosh } from '../../../../base/common/platform.js';
9
import { StringBuilder } from '../../../common/core/stringBuilder.js';
10
import { ColorId, FontStyle, TokenMetadata } from '../../../common/encodedTokenAttributes.js';
11
import type { DecorationStyleCache } from '../css/decorationStyleCache.js';
12
import { ensureNonNullable } from '../gpuUtils.js';
13
import { type IBoundingBox, type IGlyphRasterizer, type IRasterizedGlyph } from './raster.js';
14
15
let nextId = 0;
16
17
export class GlyphRasterizer extends Disposable implements IGlyphRasterizer {
18
public readonly id = nextId++;
19
20
@memoize
21
public get cacheKey(): string {
22
return `${this.fontFamily}_${this.fontSize}px`;
23
}
24
25
private _canvas: OffscreenCanvas;
26
private _ctx: OffscreenCanvasRenderingContext2D;
27
28
private readonly _textMetrics: TextMetrics;
29
30
private _workGlyph: IRasterizedGlyph = {
31
source: null!,
32
boundingBox: {
33
left: 0,
34
bottom: 0,
35
right: 0,
36
top: 0,
37
},
38
originOffset: {
39
x: 0,
40
y: 0,
41
},
42
fontBoundingBoxAscent: 0,
43
fontBoundingBoxDescent: 0,
44
};
45
private _workGlyphConfig: { chars: string | undefined; tokenMetadata: number; decorationStyleSetId: number } = { chars: undefined, tokenMetadata: 0, decorationStyleSetId: 0 };
46
47
// TODO: Support workbench.fontAliasing correctly
48
private _antiAliasing: 'subpixel' | 'greyscale' = isMacintosh ? 'greyscale' : 'subpixel';
49
50
constructor(
51
readonly fontSize: number,
52
readonly fontFamily: string,
53
readonly devicePixelRatio: number,
54
private readonly _decorationStyleCache: DecorationStyleCache,
55
) {
56
super();
57
58
const devicePixelFontSize = Math.ceil(this.fontSize * devicePixelRatio);
59
this._canvas = new OffscreenCanvas(devicePixelFontSize * 3, devicePixelFontSize * 3);
60
this._ctx = ensureNonNullable(this._canvas.getContext('2d', {
61
willReadFrequently: true,
62
alpha: this._antiAliasing === 'greyscale',
63
}));
64
this._ctx.textBaseline = 'top';
65
this._ctx.fillStyle = '#FFFFFF';
66
this._ctx.font = `${devicePixelFontSize}px ${this.fontFamily}`;
67
this._textMetrics = this._ctx.measureText('A');
68
}
69
70
/**
71
* Rasterizes a glyph. Note that the returned object is reused across different glyphs and
72
* therefore is only safe for synchronous access.
73
*/
74
public rasterizeGlyph(
75
chars: string,
76
tokenMetadata: number,
77
decorationStyleSetId: number,
78
colorMap: string[],
79
): Readonly<IRasterizedGlyph> {
80
if (chars === '') {
81
return {
82
source: this._canvas,
83
boundingBox: { top: 0, left: 0, bottom: -1, right: -1 },
84
originOffset: { x: 0, y: 0 },
85
fontBoundingBoxAscent: 0,
86
fontBoundingBoxDescent: 0,
87
};
88
}
89
// Check if the last glyph matches the config, reuse if so. This helps avoid unnecessary
90
// work when the rasterizer is called multiple times like when the glyph doesn't fit into a
91
// page.
92
if (this._workGlyphConfig.chars === chars && this._workGlyphConfig.tokenMetadata === tokenMetadata && this._workGlyphConfig.decorationStyleSetId === decorationStyleSetId) {
93
return this._workGlyph;
94
}
95
this._workGlyphConfig.chars = chars;
96
this._workGlyphConfig.tokenMetadata = tokenMetadata;
97
this._workGlyphConfig.decorationStyleSetId = decorationStyleSetId;
98
return this._rasterizeGlyph(chars, tokenMetadata, decorationStyleSetId, colorMap);
99
}
100
101
public _rasterizeGlyph(
102
chars: string,
103
tokenMetadata: number,
104
decorationStyleSetId: number,
105
colorMap: string[],
106
): Readonly<IRasterizedGlyph> {
107
const devicePixelFontSize = Math.ceil(this.fontSize * this.devicePixelRatio);
108
const canvasDim = devicePixelFontSize * 3;
109
if (this._canvas.width !== canvasDim) {
110
this._canvas.width = canvasDim;
111
this._canvas.height = canvasDim;
112
}
113
114
this._ctx.save();
115
116
// The sub-pixel x offset is the fractional part of the x pixel coordinate of the cell, this
117
// is used to improve the spacing between rendered characters.
118
const subPixelXOffset = (tokenMetadata & 0b1111) / 10;
119
120
const bgId = TokenMetadata.getBackground(tokenMetadata);
121
const bg = colorMap[bgId] ?? colorMap[ColorId.DefaultBackground];
122
123
const decorationStyleSet = this._decorationStyleCache.getStyleSet(decorationStyleSetId);
124
125
// When SPAA is used, the background color must be present to get the right glyph
126
if (this._antiAliasing === 'subpixel') {
127
this._ctx.fillStyle = bg;
128
this._ctx.fillRect(0, 0, this._canvas.width, this._canvas.height);
129
} else {
130
this._ctx.clearRect(0, 0, this._canvas.width, this._canvas.height);
131
}
132
133
const fontSb = new StringBuilder(200);
134
const fontStyle = TokenMetadata.getFontStyle(tokenMetadata);
135
if (fontStyle & FontStyle.Italic) {
136
fontSb.appendString('italic ');
137
}
138
if (decorationStyleSet?.bold !== undefined) {
139
if (decorationStyleSet.bold) {
140
fontSb.appendString('bold ');
141
}
142
} else if (fontStyle & FontStyle.Bold) {
143
fontSb.appendString('bold ');
144
}
145
fontSb.appendString(`${devicePixelFontSize}px ${this.fontFamily}`);
146
this._ctx.font = fontSb.build();
147
148
// TODO: Support FontStyle.Underline text decorations, these need to be drawn manually to
149
// the canvas. See xterm.js for "dodging" the text for underlines.
150
151
const originX = devicePixelFontSize;
152
const originY = devicePixelFontSize;
153
154
// Apply text color
155
if (decorationStyleSet?.color !== undefined) {
156
this._ctx.fillStyle = `#${decorationStyleSet.color.toString(16).padStart(8, '0')}`;
157
} else {
158
this._ctx.fillStyle = colorMap[TokenMetadata.getForeground(tokenMetadata)];
159
}
160
161
// Apply opacity
162
if (decorationStyleSet?.opacity !== undefined) {
163
this._ctx.globalAlpha = decorationStyleSet.opacity;
164
}
165
166
// The glyph baseline is top, meaning it's drawn at the top-left of the
167
// cell. Add `TextMetrics.alphabeticBaseline` to the drawn position to
168
// get the alphabetic baseline.
169
this._ctx.textBaseline = 'top';
170
171
// Draw the text
172
this._ctx.fillText(chars, originX + subPixelXOffset, originY);
173
174
// Draw strikethrough
175
if (decorationStyleSet?.strikethrough) {
176
// TODO: This position could be refined further by checking
177
// TextMetrics of lowercase letters.
178
// Position strikethrough at approximately the vertical center of
179
// lowercase letters.
180
const strikethroughY = Math.round(originY - this._textMetrics.alphabeticBaseline * 0.65);
181
const lineWidth = decorationStyleSet?.strikethroughThickness !== undefined
182
? Math.round(decorationStyleSet.strikethroughThickness * this.devicePixelRatio)
183
: Math.max(1, Math.floor(devicePixelFontSize / 10));
184
// Apply strikethrough color if specified
185
if (decorationStyleSet?.strikethroughColor !== undefined) {
186
this._ctx.fillStyle = `#${decorationStyleSet.strikethroughColor.toString(16).padStart(8, '0')}`;
187
}
188
// Intentionally do not apply the sub pixel x offset to
189
// strikethrough to ensure successive glyphs form a contiguous line.
190
this._ctx.fillRect(originX, strikethroughY - Math.floor(lineWidth / 2), Math.ceil(this._textMetrics.width), lineWidth);
191
}
192
193
this._ctx.restore();
194
195
// Extract the image data and clear the background color
196
const imageData = this._ctx.getImageData(0, 0, this._canvas.width, this._canvas.height);
197
if (this._antiAliasing === 'subpixel') {
198
const bgR = parseInt(bg.substring(1, 3), 16);
199
const bgG = parseInt(bg.substring(3, 5), 16);
200
const bgB = parseInt(bg.substring(5, 7), 16);
201
this._clearColor(imageData, bgR, bgG, bgB);
202
this._ctx.putImageData(imageData, 0, 0);
203
}
204
205
// Find the bounding box
206
this._findGlyphBoundingBox(imageData, this._workGlyph.boundingBox);
207
208
// const offset = {
209
// x: textMetrics.actualBoundingBoxLeft,
210
// y: textMetrics.actualBoundingBoxAscent
211
// };
212
// const size = {
213
// w: textMetrics.actualBoundingBoxRight + textMetrics.actualBoundingBoxLeft,
214
// y: textMetrics.actualBoundingBoxDescent + textMetrics.actualBoundingBoxAscent,
215
// wInt: Math.ceil(textMetrics.actualBoundingBoxRight + textMetrics.actualBoundingBoxLeft),
216
// yInt: Math.ceil(textMetrics.actualBoundingBoxDescent + textMetrics.actualBoundingBoxAscent),
217
// };
218
// console.log(`${chars}_${fg}`, textMetrics, boundingBox, originX, originY, { width: boundingBox.right - boundingBox.left, height: boundingBox.bottom - boundingBox.top });
219
this._workGlyph.source = this._canvas;
220
this._workGlyph.originOffset.x = this._workGlyph.boundingBox.left - originX;
221
this._workGlyph.originOffset.y = this._workGlyph.boundingBox.top - originY;
222
this._workGlyph.fontBoundingBoxAscent = this._textMetrics.fontBoundingBoxAscent;
223
this._workGlyph.fontBoundingBoxDescent = this._textMetrics.fontBoundingBoxDescent;
224
225
// const result2: IRasterizedGlyph = {
226
// source: this._canvas,
227
// boundingBox: {
228
// left: Math.floor(originX - textMetrics.actualBoundingBoxLeft),
229
// right: Math.ceil(originX + textMetrics.actualBoundingBoxRight),
230
// top: Math.floor(originY - textMetrics.actualBoundingBoxAscent),
231
// bottom: Math.ceil(originY + textMetrics.actualBoundingBoxDescent),
232
// },
233
// originOffset: {
234
// x: Math.floor(boundingBox.left - originX),
235
// y: Math.floor(boundingBox.top - originY)
236
// }
237
// };
238
239
// TODO: Verify result 1 and 2 are the same
240
241
// if (result2.boundingBox.left > result.boundingBox.left) {
242
// debugger;
243
// }
244
// if (result2.boundingBox.top > result.boundingBox.top) {
245
// debugger;
246
// }
247
// if (result2.boundingBox.right < result.boundingBox.right) {
248
// debugger;
249
// }
250
// if (result2.boundingBox.bottom < result.boundingBox.bottom) {
251
// debugger;
252
// }
253
// if (JSON.stringify(result2.originOffset) !== JSON.stringify(result.originOffset)) {
254
// debugger;
255
// }
256
257
258
259
return this._workGlyph;
260
}
261
262
private _clearColor(imageData: ImageData, r: number, g: number, b: number) {
263
for (let offset = 0; offset < imageData.data.length; offset += 4) {
264
// Check exact match
265
if (imageData.data[offset] === r &&
266
imageData.data[offset + 1] === g &&
267
imageData.data[offset + 2] === b) {
268
imageData.data[offset + 3] = 0;
269
}
270
}
271
}
272
273
// TODO: Does this even need to happen when measure text is used?
274
private _findGlyphBoundingBox(imageData: ImageData, outBoundingBox: IBoundingBox) {
275
const height = this._canvas.height;
276
const width = this._canvas.width;
277
let found = false;
278
for (let y = 0; y < height; y++) {
279
for (let x = 0; x < width; x++) {
280
const alphaOffset = y * width * 4 + x * 4 + 3;
281
if (imageData.data[alphaOffset] !== 0) {
282
outBoundingBox.top = y;
283
found = true;
284
break;
285
}
286
}
287
if (found) {
288
break;
289
}
290
}
291
outBoundingBox.left = 0;
292
found = false;
293
for (let x = 0; x < width; x++) {
294
for (let y = 0; y < height; y++) {
295
const alphaOffset = y * width * 4 + x * 4 + 3;
296
if (imageData.data[alphaOffset] !== 0) {
297
outBoundingBox.left = x;
298
found = true;
299
break;
300
}
301
}
302
if (found) {
303
break;
304
}
305
}
306
outBoundingBox.right = width;
307
found = false;
308
for (let x = width - 1; x >= outBoundingBox.left; x--) {
309
for (let y = 0; y < height; y++) {
310
const alphaOffset = y * width * 4 + x * 4 + 3;
311
if (imageData.data[alphaOffset] !== 0) {
312
outBoundingBox.right = x;
313
found = true;
314
break;
315
}
316
}
317
if (found) {
318
break;
319
}
320
}
321
outBoundingBox.bottom = outBoundingBox.top;
322
found = false;
323
for (let y = height - 1; y >= 0; y--) {
324
for (let x = 0; x < width; x++) {
325
const alphaOffset = y * width * 4 + x * 4 + 3;
326
if (imageData.data[alphaOffset] !== 0) {
327
outBoundingBox.bottom = y;
328
found = true;
329
break;
330
}
331
}
332
if (found) {
333
break;
334
}
335
}
336
}
337
338
public getTextMetrics(text: string): TextMetrics {
339
return this._ctx.measureText(text);
340
}
341
}
342
343