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
3296 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 { 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 xSubPixelXOffset = (tokenMetadata & 0b1111) / 10;
119
120
const bgId = TokenMetadata.getBackground(tokenMetadata);
121
const bg = colorMap[bgId];
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.Strikethrough and FontStyle.Underline text decorations, these
149
// need to be drawn manually to the canvas. See xterm.js for "dodging" the text for
150
// underlines.
151
152
const originX = devicePixelFontSize;
153
const originY = devicePixelFontSize;
154
if (decorationStyleSet?.color !== undefined) {
155
this._ctx.fillStyle = `#${decorationStyleSet.color.toString(16).padStart(8, '0')}`;
156
} else {
157
this._ctx.fillStyle = colorMap[TokenMetadata.getForeground(tokenMetadata)];
158
}
159
this._ctx.textBaseline = 'top';
160
161
if (decorationStyleSet?.opacity !== undefined) {
162
this._ctx.globalAlpha = decorationStyleSet.opacity;
163
}
164
165
this._ctx.fillText(chars, originX + xSubPixelXOffset, originY);
166
this._ctx.restore();
167
168
const imageData = this._ctx.getImageData(0, 0, this._canvas.width, this._canvas.height);
169
if (this._antiAliasing === 'subpixel') {
170
const bgR = parseInt(bg.substring(1, 3), 16);
171
const bgG = parseInt(bg.substring(3, 5), 16);
172
const bgB = parseInt(bg.substring(5, 7), 16);
173
this._clearColor(imageData, bgR, bgG, bgB);
174
this._ctx.putImageData(imageData, 0, 0);
175
}
176
this._findGlyphBoundingBox(imageData, this._workGlyph.boundingBox);
177
// const offset = {
178
// x: textMetrics.actualBoundingBoxLeft,
179
// y: textMetrics.actualBoundingBoxAscent
180
// };
181
// const size = {
182
// w: textMetrics.actualBoundingBoxRight + textMetrics.actualBoundingBoxLeft,
183
// y: textMetrics.actualBoundingBoxDescent + textMetrics.actualBoundingBoxAscent,
184
// wInt: Math.ceil(textMetrics.actualBoundingBoxRight + textMetrics.actualBoundingBoxLeft),
185
// yInt: Math.ceil(textMetrics.actualBoundingBoxDescent + textMetrics.actualBoundingBoxAscent),
186
// };
187
// console.log(`${chars}_${fg}`, textMetrics, boundingBox, originX, originY, { width: boundingBox.right - boundingBox.left, height: boundingBox.bottom - boundingBox.top });
188
this._workGlyph.source = this._canvas;
189
this._workGlyph.originOffset.x = this._workGlyph.boundingBox.left - originX;
190
this._workGlyph.originOffset.y = this._workGlyph.boundingBox.top - originY;
191
this._workGlyph.fontBoundingBoxAscent = this._textMetrics.fontBoundingBoxAscent;
192
this._workGlyph.fontBoundingBoxDescent = this._textMetrics.fontBoundingBoxDescent;
193
194
// const result2: IRasterizedGlyph = {
195
// source: this._canvas,
196
// boundingBox: {
197
// left: Math.floor(originX - textMetrics.actualBoundingBoxLeft),
198
// right: Math.ceil(originX + textMetrics.actualBoundingBoxRight),
199
// top: Math.floor(originY - textMetrics.actualBoundingBoxAscent),
200
// bottom: Math.ceil(originY + textMetrics.actualBoundingBoxDescent),
201
// },
202
// originOffset: {
203
// x: Math.floor(boundingBox.left - originX),
204
// y: Math.floor(boundingBox.top - originY)
205
// }
206
// };
207
208
// TODO: Verify result 1 and 2 are the same
209
210
// if (result2.boundingBox.left > result.boundingBox.left) {
211
// debugger;
212
// }
213
// if (result2.boundingBox.top > result.boundingBox.top) {
214
// debugger;
215
// }
216
// if (result2.boundingBox.right < result.boundingBox.right) {
217
// debugger;
218
// }
219
// if (result2.boundingBox.bottom < result.boundingBox.bottom) {
220
// debugger;
221
// }
222
// if (JSON.stringify(result2.originOffset) !== JSON.stringify(result.originOffset)) {
223
// debugger;
224
// }
225
226
227
228
return this._workGlyph;
229
}
230
231
private _clearColor(imageData: ImageData, r: number, g: number, b: number) {
232
for (let offset = 0; offset < imageData.data.length; offset += 4) {
233
// Check exact match
234
if (imageData.data[offset] === r &&
235
imageData.data[offset + 1] === g &&
236
imageData.data[offset + 2] === b) {
237
imageData.data[offset + 3] = 0;
238
}
239
}
240
}
241
242
// TODO: Does this even need to happen when measure text is used?
243
private _findGlyphBoundingBox(imageData: ImageData, outBoundingBox: IBoundingBox) {
244
const height = this._canvas.height;
245
const width = this._canvas.width;
246
let found = false;
247
for (let y = 0; y < height; y++) {
248
for (let x = 0; x < width; x++) {
249
const alphaOffset = y * width * 4 + x * 4 + 3;
250
if (imageData.data[alphaOffset] !== 0) {
251
outBoundingBox.top = y;
252
found = true;
253
break;
254
}
255
}
256
if (found) {
257
break;
258
}
259
}
260
outBoundingBox.left = 0;
261
found = false;
262
for (let x = 0; x < width; x++) {
263
for (let y = 0; y < height; y++) {
264
const alphaOffset = y * width * 4 + x * 4 + 3;
265
if (imageData.data[alphaOffset] !== 0) {
266
outBoundingBox.left = x;
267
found = true;
268
break;
269
}
270
}
271
if (found) {
272
break;
273
}
274
}
275
outBoundingBox.right = width;
276
found = false;
277
for (let x = width - 1; x >= outBoundingBox.left; x--) {
278
for (let y = 0; y < height; y++) {
279
const alphaOffset = y * width * 4 + x * 4 + 3;
280
if (imageData.data[alphaOffset] !== 0) {
281
outBoundingBox.right = x;
282
found = true;
283
break;
284
}
285
}
286
if (found) {
287
break;
288
}
289
}
290
outBoundingBox.bottom = outBoundingBox.top;
291
found = false;
292
for (let y = height - 1; y >= 0; y--) {
293
for (let x = 0; x < width; x++) {
294
const alphaOffset = y * width * 4 + x * 4 + 3;
295
if (imageData.data[alphaOffset] !== 0) {
296
outBoundingBox.bottom = y;
297
found = true;
298
break;
299
}
300
}
301
if (found) {
302
break;
303
}
304
}
305
}
306
307
public getTextMetrics(text: string): TextMetrics {
308
return this._ctx.measureText(text);
309
}
310
}
311
312