diff --git a/demo/index.html b/demo/index.html index 760d5990c5..f91ab4733b 100644 --- a/demo/index.html +++ b/demo/index.html @@ -53,6 +53,16 @@

Options

+

+ +

Size

diff --git a/demo/main.js b/demo/main.js index 7feb793966..3c8add277b 100644 --- a/demo/main.js +++ b/demo/main.js @@ -30,6 +30,7 @@ var terminalContainer = document.getElementById('terminal-container'), macOptionIsMeta: document.querySelector('#option-mac-option-is-meta'), scrollback: document.querySelector('#option-scrollback'), tabstopwidth: document.querySelector('#option-tabstopwidth'), + experimentalCharAtlas: document.querySelector('#option-experimental-char-atlas'), bellStyle: document.querySelector('#option-bell-style'), screenReaderMode: document.querySelector('#option-screen-reader-mode') }, @@ -87,6 +88,9 @@ optionElements.scrollback.addEventListener('change', function () { optionElements.tabstopwidth.addEventListener('change', function () { term.setOption('tabStopWidth', parseInt(optionElements.tabstopwidth.value, 10)); }); +optionElements.experimentalCharAtlas.addEventListener('change', function () { + term.setOption('experimentalCharAtlas', optionElements.experimentalCharAtlas.value); +}); optionElements.screenReaderMode.addEventListener('change', function () { term.setOption('screenReaderMode', optionElements.screenReaderMode.checked); }); diff --git a/src/Terminal.ts b/src/Terminal.ts index 4c41fb5eea..dafa6dfed7 100644 --- a/src/Terminal.ts +++ b/src/Terminal.ts @@ -75,6 +75,7 @@ const DEFAULT_OPTIONS: ITerminalOptions = { bellSound: BELL_SOUND, bellStyle: 'none', enableBold: true, + experimentalCharAtlas: 'static', fontFamily: 'courier-new, courier, monospace', fontSize: 15, fontWeight: 'normal', @@ -437,6 +438,7 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT this.renderer.clear(); this.charMeasure.measure(this.options); break; + case 'experimentalCharAtlas': case 'enableBold': case 'letterSpacing': case 'lineHeight': diff --git a/src/renderer/BaseRenderLayer.ts b/src/renderer/BaseRenderLayer.ts index 9423f65d39..9ab6938bdf 100644 --- a/src/renderer/BaseRenderLayer.ts +++ b/src/renderer/BaseRenderLayer.ts @@ -5,11 +5,11 @@ import { IRenderLayer, IColorSet, IRenderDimensions } from './Types'; import { CharData, ITerminal, ITerminalOptions } from '../Types'; -import { acquireCharAtlas, CHAR_ATLAS_CELL_SPACING } from './CharAtlas'; import { CHAR_DATA_WIDTH_INDEX, CHAR_DATA_CHAR_INDEX } from '../Buffer'; +import { ICharAtlas, INVERTED_DEFAULT_COLOR, DIM_OPACITY } from './atlas/CharAtlasUtils'; +import { acquireCharAtlas } from './atlas/CharAtlasCache'; -export const INVERTED_DEFAULT_COLOR = -1; -const DIM_OPACITY = 0.5; +export {INVERTED_DEFAULT_COLOR, DIM_OPACITY}; export abstract class BaseRenderLayer implements IRenderLayer { private _canvas: HTMLCanvasElement; @@ -21,7 +21,7 @@ export abstract class BaseRenderLayer implements IRenderLayer { private _scaledCharLeft: number = 0; private _scaledCharTop: number = 0; - private _charAtlas: HTMLCanvasElement | ImageBitmap; + private _charAtlas: ICharAtlas; constructor( private _container: HTMLElement, @@ -84,16 +84,11 @@ export abstract class BaseRenderLayer implements IRenderLayer { if (this._scaledCharWidth <= 0 && this._scaledCharHeight <= 0) { return; } - this._charAtlas = null; - const result = acquireCharAtlas(terminal, colorSet, this._scaledCharWidth, this._scaledCharHeight); - if (result instanceof HTMLCanvasElement) { - this._charAtlas = result; - } else { - result.then(bitmap => this._charAtlas = bitmap); - } + this._charAtlas = acquireCharAtlas(terminal, colorSet, this._scaledCharWidth, this._scaledCharHeight); + this._charAtlas.warmUp(); } - public resize(terminal: ITerminal, dim: IRenderDimensions, charSizeChanged: boolean): void { + public resize(terminal: ITerminal, dim: IRenderDimensions): void { this._scaledCellWidth = dim.scaledCellWidth; this._scaledCellHeight = dim.scaledCellHeight; this._scaledCharWidth = dim.scaledCharWidth; @@ -110,9 +105,8 @@ export abstract class BaseRenderLayer implements IRenderLayer { this.clearAll(); } - if (charSizeChanged) { - this._refreshCharAtlas(terminal, this._colors); - } + // Call this unconditionally. We cache char atlases, so it should be fairly cheap. + this._refreshCharAtlas(terminal, this._colors); } public abstract reset(terminal: ITerminal): void; @@ -255,46 +249,16 @@ export abstract class BaseRenderLayer implements IRenderLayer { colorIndex = 1; } } - const isAscii = code < 256; - // A color is basic if it is one of the standard normal or bold weight - // colors of the characters held in the char atlas. Note that this excludes - // the normal weight _light_ color characters. - const isBasicColor = (colorIndex > 1 && fg < 16) && (fg < 8 || bold); - const isDefaultColor = fg >= 256; - const isDefaultBackground = bg >= 256; - if (this._charAtlas && isAscii && (isBasicColor || isDefaultColor) && isDefaultBackground) { - // ImageBitmap's draw about twice as fast as from a canvas - const charAtlasCellWidth = this._scaledCharWidth + CHAR_ATLAS_CELL_SPACING; - const charAtlasCellHeight = this._scaledCharHeight + CHAR_ATLAS_CELL_SPACING; - - // Apply alpha to dim the character - if (dim) { - this._ctx.globalAlpha = DIM_OPACITY; - } - - // Draw the non-bold version of the same color if bold is not enabled - if (bold && !terminal.options.enableBold) { - // Ignore default color as it's not touched above - if (colorIndex > 1) { - colorIndex -= 8; - } - } - - this._ctx.drawImage(this._charAtlas, - code * charAtlasCellWidth, - colorIndex * charAtlasCellHeight, - charAtlasCellWidth, - this._scaledCharHeight, - x * this._scaledCellWidth + this._scaledCharLeft, - y * this._scaledCellHeight + this._scaledCharTop, - charAtlasCellWidth, - this._scaledCharHeight); - } else { + const atlasDidDraw = this._charAtlas && this._charAtlas.draw( + this._ctx, + {char, bg, fg, bold: bold && terminal.options.enableBold, dim}, + x * this._scaledCellWidth + this._scaledCharLeft, + y * this._scaledCellHeight + this._scaledCharTop + ); + + if (!atlasDidDraw) { this._drawUncachedChar(terminal, char, width, fg, x, y, bold && terminal.options.enableBold, dim); } - // This draws the atlas (for debugging purposes) - // this._ctx.clearRect(0, 0, this._canvas.width, this._canvas.height); - // this._ctx.drawImage(this._charAtlas, 0, 0); } /** diff --git a/src/renderer/atlas/CharAtlasCache.ts b/src/renderer/atlas/CharAtlasCache.ts new file mode 100644 index 0000000000..9448be2c92 --- /dev/null +++ b/src/renderer/atlas/CharAtlasCache.ts @@ -0,0 +1,88 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { ITerminal } from '../../Types'; +import { + ICharAtlas, + ICharAtlasConfig, + IGlyphIdentifier, + generateConfig, + configEquals, +} from './CharAtlasUtils'; +import NoneCharAtlas from './NoneCharAtlas'; +import DynamicCharAtlas from './DynamicCharAtlas'; +import { IColorSet } from '../Types'; + +const charAtlasImplementations = { + 'none': NoneCharAtlas, + 'static': NoneCharAtlas, // TODO: implement + 'dynamic': DynamicCharAtlas, +}; + +interface ICharAtlasCacheEntry { + atlas: ICharAtlas; + config: ICharAtlasConfig; + // N.B. This implementation potentially holds onto copies of the terminal forever, so + // this may cause memory leaks. + ownedBy: ITerminal[]; +} + +const charAtlasCache: ICharAtlasCacheEntry[] = []; + +/** + * Acquires a char atlas, either generating a new one or returning an existing + * one that is in use by another terminal. + * + * @param terminal The terminal. + * @param colors The colors to use. + */ +export function acquireCharAtlas( + terminal: ITerminal, + colors: IColorSet, + scaledCharWidth: number, + scaledCharHeight: number, +): ICharAtlas { + const newConfig = generateConfig(scaledCharWidth, scaledCharHeight, terminal, colors); + + // Check to see if the terminal already owns this config + for (let i = 0; i < charAtlasCache.length; i++) { + const entry = charAtlasCache[i]; + const ownedByIndex = entry.ownedBy.indexOf(terminal); + if (ownedByIndex >= 0) { + if (configEquals(entry.config, newConfig)) { + return entry.atlas; + } else { + // The configs differ, release the terminal from the entry + if (entry.ownedBy.length === 1) { + charAtlasCache.splice(i, 1); + } else { + entry.ownedBy.splice(ownedByIndex, 1); + } + break; + } + } + } + + // Try match a char atlas from the cache + for (let i = 0; i < charAtlasCache.length; i++) { + const entry = charAtlasCache[i]; + if (configEquals(entry.config, newConfig)) { + // Add the terminal to the cache entry and return + entry.ownedBy.push(terminal); + return entry.atlas; + } + } + + const newEntry: ICharAtlasCacheEntry = { + atlas: new charAtlasImplementations[terminal.options.experimentalCharAtlas]( + document, + newConfig, + ), + config: newConfig, + ownedBy: [terminal], + }; + charAtlasCache.push(newEntry); + return newEntry.atlas; +} diff --git a/src/renderer/atlas/CharAtlasUtils.ts b/src/renderer/atlas/CharAtlasUtils.ts new file mode 100644 index 0000000000..e1f4234206 --- /dev/null +++ b/src/renderer/atlas/CharAtlasUtils.ts @@ -0,0 +1,99 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { ITerminal } from '../../Types'; +import { ITheme } from 'xterm'; +import { IColorSet } from '../Types'; + +export interface IGlyphIdentifier { + char: string; + bg: number; + fg: number; + bold: boolean; + dim: boolean; +} + +export const CHAR_ATLAS_CELL_SPACING = 1; +export const INVERTED_DEFAULT_COLOR = -1; +export const DIM_OPACITY = 0.5; + +export interface ICharAtlasConfig { + type: 'none' | 'static' | 'dynamic'; + devicePixelRatio: number; + fontSize: number; + fontFamily: string; + fontWeight: string; + fontWeightBold: string; + scaledCharWidth: number; + scaledCharHeight: number; + allowTransparency: boolean; + colors: IColorSet; +} + +export interface ICharAtlas { + /** + * Perform any work needed to warm the cache before it can be used. + */ + warmUp(): Promise; + + /** + * May be called before warmUp finishes, however it is okay for the implementation to + * do nothing and return false in that case. + * + * @param ctx Where to draw the character onto. + * @param glyph Information about what to draw + * @param x The position on the context to start drawing at + * @param y The position on the context to start drawing at + * @returns The success state. True if we drew the character. + */ + draw( + ctx: CanvasRenderingContext2D, + glyph: IGlyphIdentifier, + x: number, + y: number + ): boolean; +} + +export function generateConfig(scaledCharWidth: number, scaledCharHeight: number, terminal: ITerminal, colors: IColorSet): ICharAtlasConfig { + const clonedColors = { + foreground: colors.foreground, + background: colors.background, + cursor: null, + cursorAccent: null, + selection: null, + ansi: colors.ansi.slice(0, 16) + }; + return { + type: terminal.options.experimentalCharAtlas, + devicePixelRatio: window.devicePixelRatio, + scaledCharWidth, + scaledCharHeight, + fontFamily: terminal.options.fontFamily, + fontSize: terminal.options.fontSize, + fontWeight: terminal.options.fontWeight, + fontWeightBold: terminal.options.fontWeightBold, + allowTransparency: terminal.options.allowTransparency, + colors: clonedColors + }; +} + +export function configEquals(a: ICharAtlasConfig, b: ICharAtlasConfig): boolean { + for (let i = 0; i < a.colors.ansi.length; i++) { + if (a.colors.ansi[i] !== b.colors.ansi[i]) { + return false; + } + } + return a.type === b.type && + a.devicePixelRatio === b.devicePixelRatio && + a.fontFamily === b.fontFamily && + a.fontSize === b.fontSize && + a.fontWeight === b.fontWeight && + a.fontWeightBold === b.fontWeightBold && + a.allowTransparency === b.allowTransparency && + a.scaledCharWidth === b.scaledCharWidth && + a.scaledCharHeight === b.scaledCharHeight && + a.colors.foreground === b.colors.foreground && + a.colors.background === b.colors.background; +} diff --git a/src/renderer/atlas/DynamicCharAtlas.ts b/src/renderer/atlas/DynamicCharAtlas.ts new file mode 100644 index 0000000000..910ec7742c --- /dev/null +++ b/src/renderer/atlas/DynamicCharAtlas.ts @@ -0,0 +1,178 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { + INVERTED_DEFAULT_COLOR, + DIM_OPACITY, + ICharAtlas, + ICharAtlasConfig, + IGlyphIdentifier, +} from './CharAtlasUtils'; + +const ATLAS_WIDTH = 30; +const ATLAS_HEIGHT = 30; + + +type GlyphCacheKey = string; + +/** + * Removes and returns the oldest element in a map. + */ +function mapShift(map: Map): [K, V] { + // Map guarantees insertion-order iteration. + const entry = map.entries().next().value; + if (entry === undefined) { + return undefined; + } + map.delete(entry[0]); + return entry; +} + +function getGlyphCacheKey(glyph: IGlyphIdentifier): GlyphCacheKey { + return `${glyph.bg}_${glyph.fg}_${glyph.bold ? 0 : 1}${glyph.dim ? 0 : 1}${glyph.char}`; +} + +export default class DynamicCharAtlas implements ICharAtlas { + private _cache: Map = new Map(); + private _cacheCanvas: HTMLCanvasElement; + private _cacheCtx: CanvasRenderingContext2D; + private _capacity: number; + private _cellHeight: number; + private _cellWidth: number; + private _height: number; + private _width: number; + + constructor(document: Document, private _config: ICharAtlasConfig) { + this._cacheCanvas = document.createElement('canvas'); + this._cacheCtx = this._cacheCanvas.getContext('2d', {alpha: false}); + this._cellWidth = this._config.scaledCharWidth, + this._cellHeight = this._config.scaledCharHeight, + this._width = ATLAS_WIDTH; + this._height = ATLAS_HEIGHT; + this._capacity = this._width * this._height; + this._cacheCanvas.width = this._cellWidth * this._width; + this._cacheCanvas.height = this._cellHeight * this._height; + // This is useful for debugging + // document.body.appendChild(this._cacheCanvas); + } + + public warmUp(): Promise { + return Promise.resolve(); + } + + public draw( + ctx: CanvasRenderingContext2D, + glyph: IGlyphIdentifier, + x: number, + y: number + ): boolean { + const glyphKey = getGlyphCacheKey(glyph); + let cachedIndex = this._cache.get(glyphKey); + if (cachedIndex != null) { + // move to end of insertion order, so this can behave like an LRU cache + this._cache.delete(glyphKey); + this._cache.set(glyphKey, cachedIndex); + this._drawFromCache(ctx, cachedIndex, x, y); + return true; + } else if (this._canCache(glyph)) { + if (this._cache.size < this._capacity) { + cachedIndex = this._cache.size; + this._cache.set(glyphKey, cachedIndex); + } else { + cachedIndex = mapShift(this._cache)[1]; + } + this._drawToCache(glyph, cachedIndex); + this._drawFromCache(ctx, cachedIndex, x, y); + return true; + } else { + return false; + } + } + + private _canCache(glyph: IGlyphIdentifier): boolean { + // Only cache ascii and extended characters for now, to be safe. In the future, we could do + // something more complicated to determine the expected width of a character. + // + // If we switch the renderer over to webgl at some point, we may be able to use blending modes + // to draw overlapping glyphs from the atlas: + // https://github.com/servo/webrender/issues/464#issuecomment-255632875 + // https://webglfundamentals.org/webgl/lessons/webgl-text-texture.html + return glyph.char.charCodeAt(0) < 256; + } + + private _toCoordinates(index: number): [number, number] { + return [ + (index % this._width) * this._cellWidth, + Math.floor(index / this._width) * this._cellHeight + ]; + } + + private _drawFromCache( + ctx: CanvasRenderingContext2D, + cacheIndex: number, + x: number, + y: number + ): void { + const [cacheX, cacheY] = this._toCoordinates(cacheIndex); + ctx.drawImage( + this._cacheCanvas, + cacheX, + cacheY, + this._config.scaledCharWidth, + this._config.scaledCharHeight, + x, + y, + this._config.scaledCharWidth, + this._config.scaledCharHeight, + ); + } + + // TODO: We do this (or something similar) in multiple places. We should split this off + // into a shared function. + private _drawToCache(glyph: IGlyphIdentifier, index: number): void { + const [x, y] = this._toCoordinates(index); + this._cacheCtx.save(); + + // Set up a clip in case the character doesn't fit in the cell + this._cacheCtx.beginPath(); + this._cacheCtx.rect(x, y, this._cellWidth, this._cellHeight); + this._cacheCtx.clip(); + + // draw the background + if (glyph.bg === INVERTED_DEFAULT_COLOR) { + this._cacheCtx.fillStyle = this._config.colors.foreground; + } else if (glyph.bg < 256) { + this._cacheCtx.fillStyle = this._config.colors.ansi[glyph.bg]; + } else { + this._cacheCtx.fillStyle = this._config.colors.background; + } + this._cacheCtx.fillRect(x, y, this._cellWidth, this._cellHeight); + + // draw the foreground/glyph + this._cacheCtx.font = + `${this._config.fontSize * this._config.devicePixelRatio}px ${this._config.fontFamily}`; + if (glyph.bold) { + this._cacheCtx.font = `bold ${this._cacheCtx.font}`; + } + this._cacheCtx.textBaseline = 'top'; + + if (glyph.fg === INVERTED_DEFAULT_COLOR) { + this._cacheCtx.fillStyle = this._config.colors.background; + } else if (glyph.fg < 256) { + // 256 color support + this._cacheCtx.fillStyle = this._config.colors.ansi[glyph.fg]; + } else { + this._cacheCtx.fillStyle = this._config.colors.foreground; + } + + // Apply alpha to dim the character + if (glyph.dim) { + this._cacheCtx.globalAlpha = DIM_OPACITY; + } + // Draw the character + this._cacheCtx.fillText(glyph.char, x, y); + this._cacheCtx.restore(); + } +} diff --git a/src/renderer/atlas/NoneCharAtlas.ts b/src/renderer/atlas/NoneCharAtlas.ts new file mode 100644 index 0000000000..cc7ba45f15 --- /dev/null +++ b/src/renderer/atlas/NoneCharAtlas.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + * + * A dummy ICharAtlas implementation that always fails to draw characters. + */ + +import { + ICharAtlas, + ICharAtlasConfig, + IGlyphIdentifier, +} from './CharAtlasUtils'; + +export default class NoneCharAtlas implements ICharAtlas { + constructor(document: Document, config: ICharAtlasConfig) { } + + public warmUp(): Promise { + return Promise.resolve(); + } + + public draw( + ctx: CanvasRenderingContext2D, + glyph: IGlyphIdentifier, + x: number, + y: number + ): boolean { + return false; + } +} diff --git a/src/renderer/atlas/StaticCharAtlas.ts b/src/renderer/atlas/StaticCharAtlas.ts new file mode 100644 index 0000000000..860d193123 --- /dev/null +++ b/src/renderer/atlas/StaticCharAtlas.ts @@ -0,0 +1,90 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { ITerminal } from '../../Types'; +import { IColorSet } from '../Types'; +import { generateCharAtlas, ICharAtlasRequest } from '../../shared/CharAtlasGenerator'; +import { + ICharAtlasConfig, + CHAR_ATLAS_CELL_SPACING, + configEquals, + generateConfig, +} from './CharAtlasUtils'; + +interface ICharAtlasCacheEntry { + bitmap: HTMLCanvasElement | Promise; + config: ICharAtlasConfig; + ownedBy: ITerminal[]; +} + +let charAtlasCache: ICharAtlasCacheEntry[] = []; + +/** + * Acquires a char atlas, either generating a new one or returning an existing + * one that is in use by another terminal. + * @param terminal The terminal. + * @param colors The colors to use. + */ +export function acquireCharAtlas(terminal: ITerminal, colors: IColorSet, scaledCharWidth: number, scaledCharHeight: number): HTMLCanvasElement | Promise { + const newConfig = generateConfig(scaledCharWidth, scaledCharHeight, terminal, colors); + + // Check to see if the terminal already owns this config + for (let i = 0; i < charAtlasCache.length; i++) { + const entry = charAtlasCache[i]; + const ownedByIndex = entry.ownedBy.indexOf(terminal); + if (ownedByIndex >= 0) { + if (configEquals(entry.config, newConfig)) { + return entry.bitmap; + } else { + // The configs differ, release the terminal from the entry + if (entry.ownedBy.length === 1) { + charAtlasCache.splice(i, 1); + } else { + entry.ownedBy.splice(ownedByIndex, 1); + } + break; + } + } + } + + // Try match a char atlas from the cache + for (let i = 0; i < charAtlasCache.length; i++) { + const entry = charAtlasCache[i]; + if (configEquals(entry.config, newConfig)) { + // Add the terminal to the cache entry and return + entry.ownedBy.push(terminal); + return entry.bitmap; + } + } + + const canvasFactory = (width: number, height: number) => { + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + return canvas; + }; + + const charAtlasConfig: ICharAtlasRequest = { + scaledCharWidth, + scaledCharHeight, + fontSize: terminal.options.fontSize, + fontFamily: terminal.options.fontFamily, + fontWeight: terminal.options.fontWeight, + fontWeightBold: terminal.options.fontWeightBold, + background: colors.background, + foreground: colors.foreground, + ansiColors: colors.ansi, + devicePixelRatio: window.devicePixelRatio, + allowTransparency: terminal.options.allowTransparency + }; + + const newEntry: ICharAtlasCacheEntry = { + bitmap: generateCharAtlas(window, canvasFactory, charAtlasConfig), + config: newConfig, + ownedBy: [terminal] + }; + charAtlasCache.push(newEntry); + return newEntry.bitmap; +} diff --git a/tsconfig.json b/tsconfig.json index 38bfd2faf6..56f140e1df 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,7 @@ "target": "es5", "rootDir": "src", "outDir": "lib", + "lib": ["DOM", "ES6", "DOM.Iterable", "ScriptHost"], "sourceMap": true, "removeComments": true }, diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index dd08b55e7b..8c3b3c2568 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -55,11 +55,29 @@ declare module 'xterm' { /** * Whether to enable the rendering of bold text. - * + * * @deprecated Use fontWeight and fontWeightBold instead. */ enableBold?: boolean; + /** + * What character atlas implementation to use. The character atlas caches drawn characters, + * speeding up rendering significantly. However, as it's currently implemented, it can create + * issues with some fonts where characters are non-integer pixel widths, or where characters + * overflow the bounds of a single cell. To mitigate these issues, we only support ASCII + * characters for now. + * + * - 'none': Don't use an atlas. + * - 'static': Generate an atlas when the terminal starts or is reconfigured. This atlas will + * only contain ASCII characters in 16 colors. + * - 'dynamic': Generate an atlas using a LRU cache as characters are requested. Limited to + * ASCII characters (for now), but supports 256 colors. + * + * Currently defaults to 'static'. This option may be removed in the future. If it is, passed + * parameters will be ignored. + */ + experimentalCharAtlas?: 'none' | 'static' | 'dynamic'; + /** * The font size used to render text. */