Path: blob/main/src/vs/editor/browser/gpu/raster/glyphRasterizer.ts
5281 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 { ColorId, 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 subPixelXOffset = (tokenMetadata & 0b1111) / 10;118119const bgId = TokenMetadata.getBackground(tokenMetadata);120const bg = colorMap[bgId] ?? colorMap[ColorId.DefaultBackground];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.Underline text decorations, these need to be drawn manually to148// the canvas. See xterm.js for "dodging" the text for underlines.149150const originX = devicePixelFontSize;151const originY = devicePixelFontSize;152153// Apply text color154if (decorationStyleSet?.color !== undefined) {155this._ctx.fillStyle = `#${decorationStyleSet.color.toString(16).padStart(8, '0')}`;156} else {157this._ctx.fillStyle = colorMap[TokenMetadata.getForeground(tokenMetadata)];158}159160// Apply opacity161if (decorationStyleSet?.opacity !== undefined) {162this._ctx.globalAlpha = decorationStyleSet.opacity;163}164165// The glyph baseline is top, meaning it's drawn at the top-left of the166// cell. Add `TextMetrics.alphabeticBaseline` to the drawn position to167// get the alphabetic baseline.168this._ctx.textBaseline = 'top';169170// Draw the text171this._ctx.fillText(chars, originX + subPixelXOffset, originY);172173// Draw strikethrough174if (decorationStyleSet?.strikethrough) {175// TODO: This position could be refined further by checking176// TextMetrics of lowercase letters.177// Position strikethrough at approximately the vertical center of178// lowercase letters.179const strikethroughY = Math.round(originY - this._textMetrics.alphabeticBaseline * 0.65);180const lineWidth = decorationStyleSet?.strikethroughThickness !== undefined181? Math.round(decorationStyleSet.strikethroughThickness * this.devicePixelRatio)182: Math.max(1, Math.floor(devicePixelFontSize / 10));183// Apply strikethrough color if specified184if (decorationStyleSet?.strikethroughColor !== undefined) {185this._ctx.fillStyle = `#${decorationStyleSet.strikethroughColor.toString(16).padStart(8, '0')}`;186}187// Intentionally do not apply the sub pixel x offset to188// strikethrough to ensure successive glyphs form a contiguous line.189this._ctx.fillRect(originX, strikethroughY - Math.floor(lineWidth / 2), Math.ceil(this._textMetrics.width), lineWidth);190}191192this._ctx.restore();193194// Extract the image data and clear the background color195const imageData = this._ctx.getImageData(0, 0, this._canvas.width, this._canvas.height);196if (this._antiAliasing === 'subpixel') {197const bgR = parseInt(bg.substring(1, 3), 16);198const bgG = parseInt(bg.substring(3, 5), 16);199const bgB = parseInt(bg.substring(5, 7), 16);200this._clearColor(imageData, bgR, bgG, bgB);201this._ctx.putImageData(imageData, 0, 0);202}203204// Find the bounding box205this._findGlyphBoundingBox(imageData, this._workGlyph.boundingBox);206207// const offset = {208// x: textMetrics.actualBoundingBoxLeft,209// y: textMetrics.actualBoundingBoxAscent210// };211// const size = {212// w: textMetrics.actualBoundingBoxRight + textMetrics.actualBoundingBoxLeft,213// y: textMetrics.actualBoundingBoxDescent + textMetrics.actualBoundingBoxAscent,214// wInt: Math.ceil(textMetrics.actualBoundingBoxRight + textMetrics.actualBoundingBoxLeft),215// yInt: Math.ceil(textMetrics.actualBoundingBoxDescent + textMetrics.actualBoundingBoxAscent),216// };217// console.log(`${chars}_${fg}`, textMetrics, boundingBox, originX, originY, { width: boundingBox.right - boundingBox.left, height: boundingBox.bottom - boundingBox.top });218this._workGlyph.source = this._canvas;219this._workGlyph.originOffset.x = this._workGlyph.boundingBox.left - originX;220this._workGlyph.originOffset.y = this._workGlyph.boundingBox.top - originY;221this._workGlyph.fontBoundingBoxAscent = this._textMetrics.fontBoundingBoxAscent;222this._workGlyph.fontBoundingBoxDescent = this._textMetrics.fontBoundingBoxDescent;223224// const result2: IRasterizedGlyph = {225// source: this._canvas,226// boundingBox: {227// left: Math.floor(originX - textMetrics.actualBoundingBoxLeft),228// right: Math.ceil(originX + textMetrics.actualBoundingBoxRight),229// top: Math.floor(originY - textMetrics.actualBoundingBoxAscent),230// bottom: Math.ceil(originY + textMetrics.actualBoundingBoxDescent),231// },232// originOffset: {233// x: Math.floor(boundingBox.left - originX),234// y: Math.floor(boundingBox.top - originY)235// }236// };237238// TODO: Verify result 1 and 2 are the same239240// if (result2.boundingBox.left > result.boundingBox.left) {241// debugger;242// }243// if (result2.boundingBox.top > result.boundingBox.top) {244// debugger;245// }246// if (result2.boundingBox.right < result.boundingBox.right) {247// debugger;248// }249// if (result2.boundingBox.bottom < result.boundingBox.bottom) {250// debugger;251// }252// if (JSON.stringify(result2.originOffset) !== JSON.stringify(result.originOffset)) {253// debugger;254// }255256257258return this._workGlyph;259}260261private _clearColor(imageData: ImageData, r: number, g: number, b: number) {262for (let offset = 0; offset < imageData.data.length; offset += 4) {263// Check exact match264if (imageData.data[offset] === r &&265imageData.data[offset + 1] === g &&266imageData.data[offset + 2] === b) {267imageData.data[offset + 3] = 0;268}269}270}271272// TODO: Does this even need to happen when measure text is used?273private _findGlyphBoundingBox(imageData: ImageData, outBoundingBox: IBoundingBox) {274const height = this._canvas.height;275const width = this._canvas.width;276let found = false;277for (let y = 0; y < height; y++) {278for (let x = 0; x < width; x++) {279const alphaOffset = y * width * 4 + x * 4 + 3;280if (imageData.data[alphaOffset] !== 0) {281outBoundingBox.top = y;282found = true;283break;284}285}286if (found) {287break;288}289}290outBoundingBox.left = 0;291found = false;292for (let x = 0; x < width; x++) {293for (let y = 0; y < height; y++) {294const alphaOffset = y * width * 4 + x * 4 + 3;295if (imageData.data[alphaOffset] !== 0) {296outBoundingBox.left = x;297found = true;298break;299}300}301if (found) {302break;303}304}305outBoundingBox.right = width;306found = false;307for (let x = width - 1; x >= outBoundingBox.left; x--) {308for (let y = 0; y < height; y++) {309const alphaOffset = y * width * 4 + x * 4 + 3;310if (imageData.data[alphaOffset] !== 0) {311outBoundingBox.right = x;312found = true;313break;314}315}316if (found) {317break;318}319}320outBoundingBox.bottom = outBoundingBox.top;321found = false;322for (let y = height - 1; y >= 0; y--) {323for (let x = 0; x < width; x++) {324const alphaOffset = y * width * 4 + x * 4 + 3;325if (imageData.data[alphaOffset] !== 0) {326outBoundingBox.bottom = y;327found = true;328break;329}330}331if (found) {332break;333}334}335}336337public getTextMetrics(text: string): TextMetrics {338return this._ctx.measureText(text);339}340}341342343