Path: blob/main/src/vs/editor/browser/gpu/raster/glyphRasterizer.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { memoize } from '../../../../base/common/decorators.js';6import { Disposable } from '../../../../base/common/lifecycle.js';7import { isMacintosh } from '../../../../base/common/platform.js';8import { StringBuilder } from '../../../common/core/stringBuilder.js';9import { FontStyle, TokenMetadata } from '../../../common/encodedTokenAttributes.js';10import type { DecorationStyleCache } from '../css/decorationStyleCache.js';11import { ensureNonNullable } from '../gpuUtils.js';12import { type IBoundingBox, type IGlyphRasterizer, type IRasterizedGlyph } from './raster.js';1314let nextId = 0;1516export class GlyphRasterizer extends Disposable implements IGlyphRasterizer {17public readonly id = nextId++;1819@memoize20public get cacheKey(): string {21return `${this.fontFamily}_${this.fontSize}px`;22}2324private _canvas: OffscreenCanvas;25private _ctx: OffscreenCanvasRenderingContext2D;2627private readonly _textMetrics: TextMetrics;2829private _workGlyph: IRasterizedGlyph = {30source: null!,31boundingBox: {32left: 0,33bottom: 0,34right: 0,35top: 0,36},37originOffset: {38x: 0,39y: 0,40},41fontBoundingBoxAscent: 0,42fontBoundingBoxDescent: 0,43};44private _workGlyphConfig: { chars: string | undefined; tokenMetadata: number; decorationStyleSetId: number } = { chars: undefined, tokenMetadata: 0, decorationStyleSetId: 0 };4546// TODO: Support workbench.fontAliasing correctly47private _antiAliasing: 'subpixel' | 'greyscale' = isMacintosh ? 'greyscale' : 'subpixel';4849constructor(50readonly fontSize: number,51readonly fontFamily: string,52readonly devicePixelRatio: number,53private readonly _decorationStyleCache: DecorationStyleCache,54) {55super();5657const devicePixelFontSize = Math.ceil(this.fontSize * devicePixelRatio);58this._canvas = new OffscreenCanvas(devicePixelFontSize * 3, devicePixelFontSize * 3);59this._ctx = ensureNonNullable(this._canvas.getContext('2d', {60willReadFrequently: true,61alpha: this._antiAliasing === 'greyscale',62}));63this._ctx.textBaseline = 'top';64this._ctx.fillStyle = '#FFFFFF';65this._ctx.font = `${devicePixelFontSize}px ${this.fontFamily}`;66this._textMetrics = this._ctx.measureText('A');67}6869/**70* Rasterizes a glyph. Note that the returned object is reused across different glyphs and71* therefore is only safe for synchronous access.72*/73public rasterizeGlyph(74chars: string,75tokenMetadata: number,76decorationStyleSetId: number,77colorMap: string[],78): Readonly<IRasterizedGlyph> {79if (chars === '') {80return {81source: this._canvas,82boundingBox: { top: 0, left: 0, bottom: -1, right: -1 },83originOffset: { x: 0, y: 0 },84fontBoundingBoxAscent: 0,85fontBoundingBoxDescent: 0,86};87}88// Check if the last glyph matches the config, reuse if so. This helps avoid unnecessary89// work when the rasterizer is called multiple times like when the glyph doesn't fit into a90// page.91if (this._workGlyphConfig.chars === chars && this._workGlyphConfig.tokenMetadata === tokenMetadata && this._workGlyphConfig.decorationStyleSetId === decorationStyleSetId) {92return this._workGlyph;93}94this._workGlyphConfig.chars = chars;95this._workGlyphConfig.tokenMetadata = tokenMetadata;96this._workGlyphConfig.decorationStyleSetId = decorationStyleSetId;97return this._rasterizeGlyph(chars, tokenMetadata, decorationStyleSetId, colorMap);98}99100public _rasterizeGlyph(101chars: string,102tokenMetadata: number,103decorationStyleSetId: number,104colorMap: string[],105): Readonly<IRasterizedGlyph> {106const devicePixelFontSize = Math.ceil(this.fontSize * this.devicePixelRatio);107const canvasDim = devicePixelFontSize * 3;108if (this._canvas.width !== canvasDim) {109this._canvas.width = canvasDim;110this._canvas.height = canvasDim;111}112113this._ctx.save();114115// The sub-pixel x offset is the fractional part of the x pixel coordinate of the cell, this116// is used to improve the spacing between rendered characters.117const xSubPixelXOffset = (tokenMetadata & 0b1111) / 10;118119const bgId = TokenMetadata.getBackground(tokenMetadata);120const bg = colorMap[bgId];121122const decorationStyleSet = this._decorationStyleCache.getStyleSet(decorationStyleSetId);123124// When SPAA is used, the background color must be present to get the right glyph125if (this._antiAliasing === 'subpixel') {126this._ctx.fillStyle = bg;127this._ctx.fillRect(0, 0, this._canvas.width, this._canvas.height);128} else {129this._ctx.clearRect(0, 0, this._canvas.width, this._canvas.height);130}131132const fontSb = new StringBuilder(200);133const fontStyle = TokenMetadata.getFontStyle(tokenMetadata);134if (fontStyle & FontStyle.Italic) {135fontSb.appendString('italic ');136}137if (decorationStyleSet?.bold !== undefined) {138if (decorationStyleSet.bold) {139fontSb.appendString('bold ');140}141} else if (fontStyle & FontStyle.Bold) {142fontSb.appendString('bold ');143}144fontSb.appendString(`${devicePixelFontSize}px ${this.fontFamily}`);145this._ctx.font = fontSb.build();146147// TODO: Support FontStyle.Strikethrough and FontStyle.Underline text decorations, these148// need to be drawn manually to the canvas. See xterm.js for "dodging" the text for149// underlines.150151const originX = devicePixelFontSize;152const originY = devicePixelFontSize;153if (decorationStyleSet?.color !== undefined) {154this._ctx.fillStyle = `#${decorationStyleSet.color.toString(16).padStart(8, '0')}`;155} else {156this._ctx.fillStyle = colorMap[TokenMetadata.getForeground(tokenMetadata)];157}158this._ctx.textBaseline = 'top';159160if (decorationStyleSet?.opacity !== undefined) {161this._ctx.globalAlpha = decorationStyleSet.opacity;162}163164this._ctx.fillText(chars, originX + xSubPixelXOffset, originY);165this._ctx.restore();166167const imageData = this._ctx.getImageData(0, 0, this._canvas.width, this._canvas.height);168if (this._antiAliasing === 'subpixel') {169const bgR = parseInt(bg.substring(1, 3), 16);170const bgG = parseInt(bg.substring(3, 5), 16);171const bgB = parseInt(bg.substring(5, 7), 16);172this._clearColor(imageData, bgR, bgG, bgB);173this._ctx.putImageData(imageData, 0, 0);174}175this._findGlyphBoundingBox(imageData, this._workGlyph.boundingBox);176// const offset = {177// x: textMetrics.actualBoundingBoxLeft,178// y: textMetrics.actualBoundingBoxAscent179// };180// const size = {181// w: textMetrics.actualBoundingBoxRight + textMetrics.actualBoundingBoxLeft,182// y: textMetrics.actualBoundingBoxDescent + textMetrics.actualBoundingBoxAscent,183// wInt: Math.ceil(textMetrics.actualBoundingBoxRight + textMetrics.actualBoundingBoxLeft),184// yInt: Math.ceil(textMetrics.actualBoundingBoxDescent + textMetrics.actualBoundingBoxAscent),185// };186// console.log(`${chars}_${fg}`, textMetrics, boundingBox, originX, originY, { width: boundingBox.right - boundingBox.left, height: boundingBox.bottom - boundingBox.top });187this._workGlyph.source = this._canvas;188this._workGlyph.originOffset.x = this._workGlyph.boundingBox.left - originX;189this._workGlyph.originOffset.y = this._workGlyph.boundingBox.top - originY;190this._workGlyph.fontBoundingBoxAscent = this._textMetrics.fontBoundingBoxAscent;191this._workGlyph.fontBoundingBoxDescent = this._textMetrics.fontBoundingBoxDescent;192193// const result2: IRasterizedGlyph = {194// source: this._canvas,195// boundingBox: {196// left: Math.floor(originX - textMetrics.actualBoundingBoxLeft),197// right: Math.ceil(originX + textMetrics.actualBoundingBoxRight),198// top: Math.floor(originY - textMetrics.actualBoundingBoxAscent),199// bottom: Math.ceil(originY + textMetrics.actualBoundingBoxDescent),200// },201// originOffset: {202// x: Math.floor(boundingBox.left - originX),203// y: Math.floor(boundingBox.top - originY)204// }205// };206207// TODO: Verify result 1 and 2 are the same208209// if (result2.boundingBox.left > result.boundingBox.left) {210// debugger;211// }212// if (result2.boundingBox.top > result.boundingBox.top) {213// debugger;214// }215// if (result2.boundingBox.right < result.boundingBox.right) {216// debugger;217// }218// if (result2.boundingBox.bottom < result.boundingBox.bottom) {219// debugger;220// }221// if (JSON.stringify(result2.originOffset) !== JSON.stringify(result.originOffset)) {222// debugger;223// }224225226227return this._workGlyph;228}229230private _clearColor(imageData: ImageData, r: number, g: number, b: number) {231for (let offset = 0; offset < imageData.data.length; offset += 4) {232// Check exact match233if (imageData.data[offset] === r &&234imageData.data[offset + 1] === g &&235imageData.data[offset + 2] === b) {236imageData.data[offset + 3] = 0;237}238}239}240241// TODO: Does this even need to happen when measure text is used?242private _findGlyphBoundingBox(imageData: ImageData, outBoundingBox: IBoundingBox) {243const height = this._canvas.height;244const width = this._canvas.width;245let found = false;246for (let y = 0; y < height; y++) {247for (let x = 0; x < width; x++) {248const alphaOffset = y * width * 4 + x * 4 + 3;249if (imageData.data[alphaOffset] !== 0) {250outBoundingBox.top = y;251found = true;252break;253}254}255if (found) {256break;257}258}259outBoundingBox.left = 0;260found = false;261for (let x = 0; x < width; x++) {262for (let y = 0; y < height; y++) {263const alphaOffset = y * width * 4 + x * 4 + 3;264if (imageData.data[alphaOffset] !== 0) {265outBoundingBox.left = x;266found = true;267break;268}269}270if (found) {271break;272}273}274outBoundingBox.right = width;275found = false;276for (let x = width - 1; x >= outBoundingBox.left; x--) {277for (let y = 0; y < height; y++) {278const alphaOffset = y * width * 4 + x * 4 + 3;279if (imageData.data[alphaOffset] !== 0) {280outBoundingBox.right = x;281found = true;282break;283}284}285if (found) {286break;287}288}289outBoundingBox.bottom = outBoundingBox.top;290found = false;291for (let y = height - 1; y >= 0; y--) {292for (let x = 0; x < width; x++) {293const alphaOffset = y * width * 4 + x * 4 + 3;294if (imageData.data[alphaOffset] !== 0) {295outBoundingBox.bottom = y;296found = true;297break;298}299}300if (found) {301break;302}303}304}305306public getTextMetrics(text: string): TextMetrics {307return this._ctx.measureText(text);308}309}310311312