diff --git a/addons/xterm-addon-serialize/src/SerializeAddon.test.ts b/addons/xterm-addon-serialize/src/SerializeAddon.test.ts index 05f2c61c7a..df4a72da6f 100644 --- a/addons/xterm-addon-serialize/src/SerializeAddon.test.ts +++ b/addons/xterm-addon-serialize/src/SerializeAddon.test.ts @@ -78,7 +78,7 @@ describe('xterm-addon-serialize', () => { terminal.loadAddon(serializeAddon); selectionService = new TestSelectionService((terminal as any)._core._bufferService); - cm = new ColorManager(document, false); + cm = new ColorManager(); (terminal as any)._core._colorManager = cm; (terminal as any)._core._selectionService = selectionService; }); diff --git a/src/browser/ColorManager.test.ts b/src/browser/ColorManager.test.ts index 019bf42a3a..cf60a1f592 100644 --- a/src/browser/ColorManager.test.ts +++ b/src/browser/ColorManager.test.ts @@ -28,7 +28,7 @@ describe('ColorManager', () => { return {data: [0, 0, 0, 0xFF]}; } }); - cm = new ColorManager(document, false); + cm = new ColorManager(); }); describe('constructor', () => { diff --git a/src/browser/ColorManager.ts b/src/browser/ColorManager.ts index 4ec0053c66..a22a423fdc 100644 --- a/src/browser/ColorManager.ts +++ b/src/browser/ColorManager.ts @@ -80,24 +80,11 @@ export const DEFAULT_ANSI_COLORS = Object.freeze((() => { */ export class ColorManager implements IColorManager { public colors: IColorSet; - private _ctx: CanvasRenderingContext2D; - private _litmusColor: CanvasGradient; + private _contrastCache: IColorContrastCache; private _restoreColors!: IRestoreColorSet; - constructor(document: Document, public allowTransparency: boolean) { - const canvas = document.createElement('canvas'); - canvas.width = 1; - canvas.height = 1; - const ctx = canvas.getContext('2d', { - willReadFrequently: true - }); - if (!ctx) { - throw new Error('Could not get rendering context'); - } - this._ctx = ctx; - this._ctx.globalCompositeOperation = 'copy'; - this._litmusColor = this._ctx.createLinearGradient(0, 0, 1, 1); + constructor() { this._contrastCache = new ColorContrastCache(); this.colors = { foreground: DEFAULT_FOREGROUND, @@ -120,9 +107,6 @@ export class ColorManager implements IColorManager { case 'minimumContrastRatio': this._contrastCache.clear(); break; - case 'allowTransparency': - this.allowTransparency = value; - break; } } @@ -134,11 +118,11 @@ export class ColorManager implements IColorManager { public setTheme(theme: ITheme = {}): void { this.colors.foreground = this._parseColor(theme.foreground, DEFAULT_FOREGROUND); this.colors.background = this._parseColor(theme.background, DEFAULT_BACKGROUND); - this.colors.cursor = this._parseColor(theme.cursor, DEFAULT_CURSOR, true); - this.colors.cursorAccent = this._parseColor(theme.cursorAccent, DEFAULT_CURSOR_ACCENT, true); - this.colors.selectionBackgroundTransparent = this._parseColor(theme.selectionBackground, DEFAULT_SELECTION, true); + this.colors.cursor = this._parseColor(theme.cursor, DEFAULT_CURSOR); + this.colors.cursorAccent = this._parseColor(theme.cursorAccent, DEFAULT_CURSOR_ACCENT); + this.colors.selectionBackgroundTransparent = this._parseColor(theme.selectionBackground, DEFAULT_SELECTION); this.colors.selectionBackgroundOpaque = color.blend(this.colors.background, this.colors.selectionBackgroundTransparent); - this.colors.selectionInactiveBackgroundTransparent = this._parseColor(theme.selectionInactiveBackground, this.colors.selectionBackgroundTransparent, true); + this.colors.selectionInactiveBackgroundTransparent = this._parseColor(theme.selectionInactiveBackground, this.colors.selectionBackgroundTransparent); this.colors.selectionInactiveBackgroundOpaque = color.blend(this.colors.background, this.colors.selectionInactiveBackgroundTransparent); const nullColor: IColor = { css: '', @@ -222,69 +206,16 @@ export class ColorManager implements IColorManager { } private _parseColor( - css: string | undefined, - fallback: IColor, - allowTransparency: boolean = this.allowTransparency + cssString: string | undefined, + fallback: IColor ): IColor { - if (css === undefined) { - return fallback; - } - - // If parsing the value results in failure, then it must be ignored, and the attribute must - // retain its previous value. - // -- https://html.spec.whatwg.org/multipage/canvas.html#fill-and-stroke-styles - this._ctx.fillStyle = this._litmusColor; - this._ctx.fillStyle = css; - if (typeof this._ctx.fillStyle !== 'string') { - console.warn(`Color: ${css} is invalid using fallback ${fallback.css}`); - return fallback; - } - - this._ctx.fillRect(0, 0, 1, 1); - const data = this._ctx.getImageData(0, 0, 1, 1).data; - - // Check if the printed color was transparent - if (data[3] !== 0xFF) { - if (!allowTransparency) { - // Ideally we'd just ignore the alpha channel, but... - // - // Browsers may not give back exactly the same RGB values we put in, because most/all - // convert the color to a pre-multiplied representation. getImageData converts that back to - // a un-premultipled representation, but the precision loss may make the RGB channels unuable - // on their own. - // - // E.g. In Chrome #12345610 turns into #10305010, and in the extreme case, 0xFFFFFF00 turns - // into 0x00000000. - // - // "Note: Due to the lossy nature of converting to and from premultiplied alpha color values, - // pixels that have just been set using putImageData() might be returned to an equivalent - // getImageData() as different values." - // -- https://html.spec.whatwg.org/multipage/canvas.html#pixel-manipulation - // - // So let's just use the fallback color in this case instead. - console.warn( - `Color: ${css} is using transparency, but allowTransparency is false. ` + - `Using fallback ${fallback.css}.` - ); - return fallback; + if (cssString !== undefined) { + try { + return css.toColor(cssString); + } catch { + // no-op } - - // https://html.spec.whatwg.org/multipage/canvas.html#serialisation-of-a-color - // the color value has alpha less than 1.0, and the string is the color value in the CSS rgba() - const [r, g, b, a] = this._ctx.fillStyle.substring(5, this._ctx.fillStyle.length - 1).split(',').map(component => Number(component)); - const alpha = Math.round(a * 255); - const rgba: number = channels.toRgba(r, g, b, alpha); - return { - rgba, - css - }; } - - return { - // https://html.spec.whatwg.org/multipage/canvas.html#serialisation-of-a-color - // if it has alpha equal to 1.0, then the string is a lowercase six-digit hex value, prefixed with a "#" character - css: this._ctx.fillStyle, - rgba: channels.toRgba(data[0], data[1], data[2], data[3]) - }; + return fallback; } } diff --git a/src/browser/Terminal.ts b/src/browser/Terminal.ts index 9aba38186d..afdac748c4 100644 --- a/src/browser/Terminal.ts +++ b/src/browser/Terminal.ts @@ -502,7 +502,7 @@ export class Terminal extends CoreTerminal implements ITerminal { this._instantiationService.setService(ICharSizeService, this._charSizeService); this._theme = this.options.theme || this._theme; - this._colorManager = new ColorManager(document, this.options.allowTransparency); + this._colorManager = new ColorManager(); this.register(this.optionsService.onOptionChange(e => this._colorManager!.onOptionsChange(e, this.optionsService.rawOptions[e]))); this._colorManager.setTheme(this._theme); diff --git a/src/common/Color.ts b/src/common/Color.ts index a66e39c1bf..6e70671b57 100644 --- a/src/common/Color.ts +++ b/src/common/Color.ts @@ -3,8 +3,14 @@ * @license MIT */ +import { isNode } from 'common/Platform'; import { IColor, IColorRGB } from 'common/Types'; +let $r = 0; +let $g = 0; +let $b = 0; +let $a = 0; + /** * Helper functions where the source type is "channels" (individual color channels as numbers). */ @@ -29,8 +35,8 @@ export namespace channels { */ export namespace color { export function blend(bg: IColor, fg: IColor): IColor { - const a = (fg.rgba & 0xFF) / 255; - if (a === 1) { + $a = (fg.rgba & 0xFF) / 255; + if ($a === 1) { return { css: fg.css, rgba: fg.rgba @@ -42,11 +48,11 @@ export namespace color { const bgR = (bg.rgba >> 24) & 0xFF; const bgG = (bg.rgba >> 16) & 0xFF; const bgB = (bg.rgba >> 8) & 0xFF; - const r = bgR + Math.round((fgR - bgR) * a); - const g = bgG + Math.round((fgG - bgG) * a); - const b = bgB + Math.round((fgB - bgB) * a); - const css = channels.toCss(r, g, b); - const rgba = channels.toRgba(r, g, b); + $r = bgR + Math.round((fgR - bgR) * $a); + $g = bgG + Math.round((fgG - bgG) * $a); + $b = bgB + Math.round((fgB - bgB) * $a); + const css = channels.toCss($r, $g, $b); + const rgba = channels.toRgba($r, $g, $b); return { css, rgba }; } @@ -68,25 +74,25 @@ export namespace color { export function opaque(color: IColor): IColor { const rgbaColor = (color.rgba | 0xFF) >>> 0; - const [r, g, b] = rgba.toChannels(rgbaColor); + [$r, $g, $b] = rgba.toChannels(rgbaColor); return { - css: channels.toCss(r, g, b), + css: channels.toCss($r, $g, $b), rgba: rgbaColor }; } export function opacity(color: IColor, opacity: number): IColor { - const a = Math.round(opacity * 0xFF); - const [r, g, b] = rgba.toChannels(color.rgba); + $a = Math.round(opacity * 0xFF); + [$r, $g, $b] = rgba.toChannels(color.rgba); return { - css: channels.toCss(r, g, b, a), - rgba: channels.toRgba(r, g, b, a) + css: channels.toCss($r, $g, $b, $a), + rgba: channels.toRgba($r, $g, $b, $a) }; } export function multiplyOpacity(color: IColor, factor: number): IColor { - const a = color.rgba & 0xFF; - return opacity(color, (a * factor) / 0xFF); + $a = color.rgba & 0xFF; + return opacity(color, ($a * factor) / 0xFF); } export function toColorRGB(color: IColor): IColorRGB { @@ -98,21 +104,45 @@ export namespace color { * Helper functions where the source type is "css" (string: '#rgb', '#rgba', '#rrggbb', '#rrggbbaa'). */ export namespace css { + let $ctx: CanvasRenderingContext2D | undefined; + let $litmusColor: CanvasGradient | undefined; + if (!isNode) { + const canvas = document.createElement('canvas'); + canvas.width = 1; + canvas.height = 1; + const ctx = canvas.getContext('2d', { + willReadFrequently: true + }); + if (ctx) { + $ctx = ctx; + $ctx.globalCompositeOperation = 'copy'; + $litmusColor = $ctx.createLinearGradient(0, 0, 1, 1); + } + } + + /** + * Converts a css string to an IColor, this should handle all valid CSS color strings and will + * throw if it's invalid. The ideal format to use is `#rrggbb[aa]` as it's the fastest to parse. + * + * Only `#rgb[a]`, `#rrggbb[aa]`, `rgb()` and `rgba()` formats are supported when run in a Node + * environment. + */ export function toColor(css: string): IColor { + // Formats: #rgb[a] and #rrggbb[aa] if (css.match(/#[0-9a-f]{3,8}/i)) { switch (css.length) { case 4: { // #rgb - const r = parseInt(css.slice(1, 2).repeat(2), 16); - const g = parseInt(css.slice(2, 3).repeat(2), 16); - const b = parseInt(css.slice(3, 4).repeat(2), 16); - return rgba.toColor(r, g, b); + $r = parseInt(css.slice(1, 2).repeat(2), 16); + $g = parseInt(css.slice(2, 3).repeat(2), 16); + $b = parseInt(css.slice(3, 4).repeat(2), 16); + return rgba.toColor($r, $g, $b); } case 5: { // #rgba - const r = parseInt(css.slice(1, 2).repeat(2), 16); - const g = parseInt(css.slice(2, 3).repeat(2), 16); - const b = parseInt(css.slice(3, 4).repeat(2), 16); - const a = parseInt(css.slice(4, 5).repeat(2), 16); - return rgba.toColor(r, g, b, a); + $r = parseInt(css.slice(1, 2).repeat(2), 16); + $g = parseInt(css.slice(2, 3).repeat(2), 16); + $b = parseInt(css.slice(3, 4).repeat(2), 16); + $a = parseInt(css.slice(4, 5).repeat(2), 16); + return rgba.toColor($r, $g, $b, $a); } case 7: // #rrggbb return { @@ -126,15 +156,45 @@ export namespace css { }; } } + + // Formats: rgb() or rgba() const rgbaMatch = css.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/); - if (rgbaMatch) { // rgb() or rgba() - const r = parseInt(rgbaMatch[1]); - const g = parseInt(rgbaMatch[2]); - const b = parseInt(rgbaMatch[3]); - const a = Math.round((rgbaMatch[5] === undefined ? 1 : parseFloat(rgbaMatch[5])) * 0xFF); - return rgba.toColor(r, g, b, a); + if (rgbaMatch) { + $r = parseInt(rgbaMatch[1]); + $g = parseInt(rgbaMatch[2]); + $b = parseInt(rgbaMatch[3]); + $a = Math.round((rgbaMatch[5] === undefined ? 1 : parseFloat(rgbaMatch[5])) * 0xFF); + return rgba.toColor($r, $g, $b, $a); } - throw new Error('css.toColor: Unsupported css format'); + + // Validate the context is available for canvas-based color parsing + if (!$ctx || !$litmusColor) { + throw new Error('css.toColor: Unsupported css format'); + } + + // Validate the color using canvas fillStyle + // See https://html.spec.whatwg.org/multipage/canvas.html#fill-and-stroke-styles + $ctx.fillStyle = $litmusColor; + $ctx.fillStyle = css; + if (typeof $ctx.fillStyle !== 'string') { + throw new Error('css.toColor: Unsupported css format'); + } + + $ctx.fillRect(0, 0, 1, 1); + [$r, $g, $b, $a] = $ctx.getImageData(0, 0, 1, 1).data; + + // Validate the color is non-transparent as color hue gets lost when drawn to the canvas + if ($a !== 0xFF) { + throw new Error('css.toColor: Unsupported css format'); + } + + // Extract the color from the canvas' fillStyle property which exposes the color value in rgba() + // format + // See https://html.spec.whatwg.org/multipage/canvas.html#serialisation-of-a-color + return { + rgba: channels.toRgba($r, $g, $b, $a), + css + }; } } diff --git a/src/common/Platform.ts b/src/common/Platform.ts index 7b823b122c..6be0584f90 100644 --- a/src/common/Platform.ts +++ b/src/common/Platform.ts @@ -13,7 +13,7 @@ interface INavigator { // we want this module to live in common. declare const navigator: INavigator; -const isNode = (typeof navigator === 'undefined') ? true : false; +export const isNode = (typeof navigator === 'undefined') ? true : false; const userAgent = (isNode) ? 'node' : navigator.userAgent; const platform = (isNode) ? 'node' : navigator.platform;